Compare commits

..
53 changed files with 713 additions and 2806 deletions

No files matched your search

+9 -9
View File
@@ -8,20 +8,20 @@
# Core AI and Automation Framework
# Note: aiosqlite is in core requirements.txt
langchain>=1.2.10
langchain-core>=1.7.3
langgraph>=1.2.19
langchain-core>=1.2.16
langgraph>=1.0.9
langgraph-checkpoint>=4.0.0
langgraph-checkpoint-sqlite>=3.1.3
langgraph-checkpoint-sqlite>=3.1.2
langgraph-checkpoint-postgres>=3.0.4
# Model Providers
langchain-openai>=1.7.3
langchain-anthropic>=1.7.10
langchain-openai>=1.7.2
langchain-anthropic>=1.3.4
langchain-google-genai>=4.4.2
langchain-aws>=1.9.0
langchain-ollama>=1.1.0
langchain-deepseek>=1.1.1
langchain-xai>=1.3.0
langchain-xai>=1.2.2
# Token Counting
tiktoken>=0.14.0
@@ -30,15 +30,15 @@ tiktoken>=0.14.0
langsmith>=0.14.7
# Network Automation
paramiko>=5.1.0 # install version >= 5.0.0 to address CVE-2026-44405
paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405
netmiko>=4.8.0
nornir>=3.6.1
nornir>=3.6.0
nornir-netmiko>=1.0.1
nornir-utils>=0.3.0
nornir-salt>=0.23.4
# Environment & Configuration
python-dotenv>=1.2.4
python-dotenv>=1.2.1
# Authentication
PyJWT>=2.15.1
+1 -1
View File
@@ -1 +1 @@
{"openapi": "3.1.0", "info": {"title": "GNS3 controller API", "description": "This page describes the public controller API for GNS3", "version": "3.1.0.dev8"}, "servers": [{"url": "/"}], "paths": {"/v3/version": {"get": {"tags": ["Controller"], "summary": "Get Version", "description": "Return the server version number.", "operationId": "controller_get_version", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}}}, "post": {"tags": ["Controller"], "summary": "Check Version", "description": "Check if version is the same as the server.", "operationId": "controller_check_version", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}, "409": {"description": "Invalid version", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}}}, "/v3/reload": {"post": {"tags": ["Controller"], "summary": "Reload", "description": "Reload the controller", "operationId": "controller_reload", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/shutdown": {"post": {"tags": ["Controller"], "summary": "Shutdown", "description": "Shutdown the server", "operationId": "controller_shutdown", "responses": {"204": {"description": "Successful Response"}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/iou_license": {"get": {"tags": ["Controller"], "summary": "Get Iou License", "description": "Return the IOU license settings", "operationId": "controller_get_iou_license", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["Controller"], "summary": "Update Iou License", "description": "Update the IOU license settings.", "operationId": "controller_update_iou_license", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/statistics": {"get": {"tags": ["Controller"], "summary": "Statistics", "description": "Return server statistics including compute resources, projects, and nodes.", "operationId": "controller_statistics", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerStatistics"}}}}, "422": {"description": "Request validation error", "content": {"application/jLine truncated
{"openapi": "3.1.0", "info": {"title": "GNS3 controller API", "description": "This page describes the public controller API for GNS3", "version": "3.0.0"}, "paths": {"/v3/version": {"get": {"tags": ["Controller"], "summary": "Get Version", "description": "Return the server version number.", "operationId": "get_version_v3_version_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}}}, "post": {"tags": ["Controller"], "summary": "Check Version", "description": "Check if version is the same as the server.", "operationId": "check_version_v3_version_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}, "409": {"description": "Invalid version", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/reload": {"post": {"tags": ["Controller"], "summary": "Reload", "description": "Reload the controller", "operationId": "reload_v3_reload_post", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/shutdown": {"post": {"tags": ["Controller"], "summary": "Shutdown", "description": "Shutdown the server", "operationId": "shutdown_v3_shutdown_post", "responses": {"204": {"description": "Successful Response"}, "403": {"description": "Server shutdown not allowed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/iou_license": {"get": {"tags": ["Controller"], "summary": "Get Iou License", "description": "Return the IOU license settings", "operationId": "get_iou_license_v3_iou_license_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["Controller"], "summary": "Update Iou License", "description": "Update the IOU license settings.", "operationId": "update_iou_license_v3_iou_license_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/statistics": {"get": {"tags": ["Controller"], "summary": "Statistics", "description": "Return server statistics including compute resources, projects, and nodes.", "operationId": "statistics_v3_statistics_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": true, "type": "object", "title": "Response Statistics V3 Statistics Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/notifications": {"get": {"tags": ["Controller"], "summary": "Controller Http Notifications", "description": "Receive controller notifications about the controller from HTTP stream.", "operationId": "controller_http_notifications_v3_notifications_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/settings": {"get": {"tags": ["Server settings"], "summary": "Get Server Settings", "description": "Return the server settings.\n\nThe values reflect the running configuration (which may include command\nline overrides). Secret fields are masked.", "operationId": "get_server_settings_v3_settings_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SettingsResponse"}}}}, "401": {"description": "Unauthorized", "cLine truncated
@@ -67,7 +67,6 @@ compute_api = FastAPI(
)
compute_api.state.controller_host = None
compute_api.state.exiting = False
@compute_api.exception_handler(ComputeError)
+3 -74
View File
@@ -19,24 +19,16 @@ API routes for images.
"""
import os
import time
import urllib.parse
from typing import List, Optional
from fastapi import APIRouter, Body, HTTPException, Path, Query, Request, status
from fastapi import APIRouter, Body, HTTPException, Query, Request, status
from fastapi.responses import FileResponse
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.iou import IOU
from gns3server.compute.notification_manager import NotificationManager
from gns3server.compute.qemu import Qemu
from gns3server.utils.images import DOCKER_IMAGE_NAME_PATTERN, validate_docker_image_name
# Daemon progress lines arrive dozens of times per second per layer: emit at
# most one downloading event per second per pull.
PULL_PROGRESS_INTERVAL = 1.0
router = APIRouter()
@@ -52,55 +44,13 @@ async def get_docker_images() -> List[dict]:
@router.post("/docker/images/pull", status_code=status.HTTP_204_NO_CONTENT)
async def pull_docker_image(
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$"),
progress_id: Optional[str] = Query(
None, pattern=r"^\S+$", description="Client supplied id to correlate progress notifications"
),
) -> None:
async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")) -> None:
"""
Pull or update a Docker image.
The pull progress is reported as docker.image.pull notifications: a
started event, throttled downloading events and a terminal completed or
failed event. The optional progress_id is echoed in every event so a
client can correlate them with its own request.
"""
docker_manager = Docker.instance()
def emit(state: str, message: Optional[str] = None, progress: Optional[dict] = None) -> None:
event = {"image": image, "state": state}
if message is not None:
event["message"] = message
if progress is not None:
event["layers_total"] = progress["layers_total"]
event["layers_done"] = progress["layers_done"]
if progress_id is not None:
event["progress_id"] = progress_id
NotificationManager.instance().emit("docker.image.pull", event)
last_emit = 0.0
def progress_callback(progress: dict) -> None:
nonlocal last_emit
# the started event comes from the pull itself, once it owns the lock,
# and is never throttled
if progress.get("state") == "started":
emit("started", progress["message"])
return
now = time.monotonic()
if now - last_emit < PULL_PROGRESS_INTERVAL:
return
last_emit = now
emit("downloading", progress["message"], progress)
try:
await docker_manager.pull_image(image, progress_callback=progress_callback, force=True)
except DockerError as e:
emit("failed", str(e))
raise
emit("completed", f"Success pulling image {image}")
await docker_manager.pull_image(image, force=True)
@router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT)
@@ -123,27 +73,6 @@ async def load_docker_image(
await docker_manager.load_image(request.stream(), image=image, digest=digest)
@router.delete("/docker/images/{image:path}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_docker_image(
image: str = Path(..., pattern=DOCKER_IMAGE_NAME_PATTERN),
force: bool = Query(False, description="Remove the image even if a container uses it"),
) -> None:
"""
Delete a Docker image from the Docker daemon.
The daemon refuses to delete an image used by a container (even a stopped
one) and answers a conflict, unless force is set.
"""
image = urllib.parse.unquote(image)
try:
validate_docker_image_name(image)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) from e
docker_manager = Docker.instance()
await docker_manager.delete_image(image, force=force)
@router.get("/dynamips/images")
async def get_dynamips_images() -> List[dict]:
"""
@@ -44,7 +44,7 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
log.info(f"New client {client} has connected to compute WebSocket")
try:
with NotificationManager.instance().queue() as queue:
while not websocket.app.state.exiting:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
+3
View File
@@ -203,6 +203,9 @@ async def create_ace(
Required privilege: ACE.Allocate
"""
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
print(route_path)
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
# remove the prefix (e.g. "/v3") from the route path
normalized_path = re.sub(r"^/v[0-9]", "", route_path)
+3 -104
View File
@@ -21,7 +21,7 @@ API routes for computes.
from typing import Any, List, Optional, Union
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
from fastapi import APIRouter, Body, Depends, Query, status
import gns3server.db.models as models
from gns3server import schemas
@@ -29,13 +29,7 @@ from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.services.computes import ComputesService
from gns3server.utils.images import (
DOCKER_IMAGE_NAME_PATTERN,
canonical_docker_image_name,
validate_docker_image_name,
)
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
@@ -221,111 +215,16 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput
dependencies=[Depends(has_privilege("Compute.Modify"))],
)
async def docker_pull_image(
compute_id: Union[str, UUID],
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$"),
progress_id: Optional[str] = Query(
None, pattern=r"^\S+$", description="Client supplied id to correlate progress notifications"
),
compute_id: Union[str, UUID], image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")
) -> None:
"""
Pull or update a Docker image on a compute.
The pull progress is reported on the controller notification stream as
docker.image.pull events (started, downloading, completed or failed). The
optional progress_id is echoed in every event so a client can correlate
them with its own request.
Required privilege: Compute.Modify
"""
compute = Controller.instance().get_compute(str(compute_id))
params = {"progress_id": progress_id} if progress_id is not None else None
await compute.forward("POST", "docker", "images/pull", data={"image": image}, params=params)
@router.post(
"/{compute_id}/docker/images/load",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Compute.Modify"))],
)
async def docker_load_image(compute_id: Union[str, UUID], request: Request) -> None:
"""
Load a Docker image on a compute from a docker save tar stream.
The tar archive (plain or gzip compressed) is streamed to the compute
and loaded into its Docker daemon, without buffering the whole file
in the controller memory.
Required privilege: Compute.Modify
"""
compute = Controller.instance().get_compute(str(compute_id))
await compute.http_query("POST", "/docker/images/load", data=request.stream(), timeout=None)
@router.delete(
"/{compute_id}/docker/images/{image:path}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Compute.Modify"))],
)
async def docker_delete_image(
compute_id: Union[str, UUID],
image: str = Path(..., pattern=DOCKER_IMAGE_NAME_PATTERN),
force: bool = Query(False, description="Remove the image even if a container uses it"),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> None:
"""
Delete a Docker image on a compute.
The deletion is refused while templates reference the image, unless force
is set. The compute also refuses an image used by a container (even a
stopped one) and answers a conflict naming the GNS3 nodes and projects
using it.
After a successful deletion a docker.image.deleted notification is
emitted on the controller notification stream, with the templates that
referenced the image, so clients can refresh their image lists.
Required privilege: Compute.Modify
"""
try:
validate_docker_image_name(image)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) from e
canonical_image = canonical_docker_image_name(image)
referencing_templates = [
str(template.name)
for template in await templates_repo.get_templates()
if isinstance(template, models.DockerTemplate)
and template.image
and canonical_docker_image_name(str(template.image)) == canonical_image
]
if referencing_templates and not force:
# refuse before touching the compute: the client can prompt the user
# and repeat with force once the user confirms
raise ControllerError(
f"Image '{image}' is referenced by template(s): {', '.join(referencing_templates)}. "
f"Delete the templates first or repeat with force=true.",
code="image_referenced_by_templates",
details={"referenced_by_templates": referencing_templates},
)
compute = Controller.instance().get_compute(str(compute_id))
await compute.http_query("DELETE", f"/docker/images/{image}", params={"force": str(force).lower()})
# tell the clients the image changed on this compute: they can refresh
# their image lists, and the templates that referenced the image can no
# longer start their nodes
Controller.instance().notification.controller_emit(
"docker.image.deleted",
{
"compute_id": str(compute_id),
"image": image,
"referenced_by_templates": referencing_templates,
},
)
await compute.forward("POST", "docker", "images/pull", data={"image": image})
@router.get("/{compute_id}/virtualbox/vms", response_model=List[schemas.ComputeVirtualBoxVM])
@@ -308,7 +308,7 @@ async def controller_ws_notifications(
log.info(f"New client {client} has connected to controller WebSocket")
try:
with Controller.instance().notification.controller_queue() as queue:
while not websocket.app.state.exiting:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
+1 -1
View File
@@ -140,7 +140,7 @@ async def delete_user_group(
success = await users_repo.delete_user_group(user_group_id)
if not success:
raise ControllerError(f"User group '{user_group_id}' could not be deleted")
await rbac_repo.delete_all_ace_starting_with_path(f"/access/groups/{user_group_id}")
await rbac_repo.delete_all_ace_starting_with_path(f"/groups/{user_group_id}")
@router.get(
+4 -50
View File
@@ -126,12 +126,10 @@ async def get_projects(
# Step 2: Filter direct ACE projects by created_by
# Direct project sharing is only available through resource pools
# Projects created before the created_by field existed (GNS3 < 3.1) have no
# owner: fall back to the ACE-only visibility rule instead of hiding them.
for p in controller.projects.values():
if name is not None and p.name != name:
continue
if p.id in direct_ace_ids and (p.created_by is None or p.created_by == current_user.username):
if p.id in direct_ace_ids and p.created_by == current_user.username:
if p.id not in seen_project_ids:
projects.append(p.asdict())
seen_project_ids.add(p.id)
@@ -191,46 +189,6 @@ def get_project(response: Response, project: Project = Depends(dep_project)) ->
return project_dict
@router.get(
"/{project_id}/me/privileges",
response_model=schemas.ProjectPrivileges,
responses=GET_RESPONSES,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def get_logged_in_user_privileges_on_project(
current_user: schemas.User = Depends(get_current_active_user),
project: Project = Depends(dep_project),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.ProjectPrivileges:
"""
Get the effective privileges of the current user on this project.
Unlike /access/users/me/privileges (flattened across all resource paths),
each privilege is evaluated on the project's resource path, so the result
reflects ACEs scoped to this project and resource pools containing it.
This is meant for rendering purposes (e.g. showing or hiding buttons in
the project view): the server remains the authority for permission checks.
Required privilege: Project.Audit
Example: curl -X GET http://host:port/v3/projects/{project_id}/me/privileges -H "Authorization: Bearer <token>"
"""
if current_user.is_superadmin:
privilege_names = sorted(privilege.name for privilege in await rbac_repo.get_privileges())
return schemas.ProjectPrivileges(is_superadmin=True, privileges=privilege_names)
path = f"/projects/{project.id}"
# the flat view is the superset: only privileges granted somewhere can be
# granted on this path, so only those need to be evaluated against it
candidates = await rbac_repo.get_user_privileges(current_user.user_id)
project_privileges = []
for name in candidates:
if await rbac_repo.check_user_has_privilege(current_user.user_id, path, name):
project_privileges.append(name)
return schemas.ProjectPrivileges(is_superadmin=False, privileges=sorted(project_privileges))
@router.put(
"/{project_id}",
response_model=schemas.Project,
@@ -701,7 +659,7 @@ async def project_ws_notifications(
log.info(f"New client has connected to the notification stream for project ID '{project.id}' (WebSocket method)")
try:
with controller.notification.project_queue(project.id) as queue:
while not websocket.app.state.exiting:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
@@ -743,7 +701,7 @@ async def project_marker_ws_notifications(
)
try:
with controller.notification.project_marker_queue(project.id) as queue:
while not websocket.app.state.exiting:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
@@ -926,11 +884,7 @@ async def duplicate_project(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
reset_mac_addresses = project_data.reset_mac_addresses
new_project = await project.duplicate(
name=project_data.name,
reset_mac_addresses=reset_mac_addresses,
user=current_user.username,
)
new_project = await project.duplicate(name=project_data.name, reset_mac_addresses=reset_mac_addresses)
# Add the new project in the same resource pools if the duplicated project belongs to any
if pool_memberships:
+1 -1
View File
@@ -135,7 +135,7 @@ async def delete_role(
success = await rbac_repo.delete_role(role_id)
if not success:
raise ControllerError(f"Role '{role_id}' could not be deleted")
await rbac_repo.delete_all_ace_starting_with_path(f"/access/roles/{role_id}")
await rbac_repo.delete_all_ace_starting_with_path(f"/roles/{role_id}")
@router.get(
+1 -26
View File
@@ -165,31 +165,6 @@ async def update_logged_in_user(
return await users_repo.update_user(current_user.user_id, user_update)
@router.get("/me/privileges", response_model=schemas.UserPrivileges)
async def get_logged_in_user_privileges(
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.UserPrivileges:
"""
Get the effective privileges of the current active user.
The privileges are the union of everything granted by the roles assigned
to the user (directly or through group membership), flattened across all
resource paths. This is meant for rendering purposes (e.g. showing or
hiding parts of the UI): the server remains the authority for permission
checks and can still deny a privilege on a specific resource path.
Example: curl -X GET http://host:port/v3/access/users/me/privileges -H "Authorization: Bearer <token>"
"""
if current_user.is_superadmin:
privileges = sorted(privilege.name for privilege in await rbac_repo.get_privileges())
return schemas.UserPrivileges(is_superadmin=True, privileges=privileges)
privilege_names = await rbac_repo.get_user_privileges(current_user.user_id)
return schemas.UserPrivileges(is_superadmin=False, privileges=sorted(privilege_names))
@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[models.User]:
"""
@@ -290,7 +265,7 @@ async def delete_user(
success = await users_repo.delete_user(user_id)
if not success:
raise ControllerError(f"User '{user_id}' could not be deleted")
await rbac_repo.delete_all_ace_starting_with_path(f"/access/users/{user_id}")
await rbac_repo.delete_all_ace_starting_with_path(f"/users/{user_id}")
@router.get(
-2
View File
@@ -134,8 +134,6 @@ unicorn_exit_handler = UvicornServer.handle_exit
def handle_exit(*args, **kwargs):
app.state.exiting = True
# The compute API is a mounted sub-app with its own State object.
compute_api.state.exiting = True
unicorn_exit_handler(*args, **kwargs)
+179 -5
View File
@@ -35,12 +35,12 @@ from gns3server.compute.docker.docker_error import (
DockerHttp404Error,
DockerHttp409Error,
)
from gns3server.compute.docker.docker_images import DockerImagesMixin
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.config import Config
from gns3server.utils import parse_version
from gns3server.utils.asyncio import locking
log = logging.getLogger(__name__)
@@ -49,9 +49,10 @@ log = logging.getLogger(__name__)
DOCKER_MINIMUM_API_VERSION = "1.40"
DOCKER_MINIMUM_VERSION = "19.03.8"
DOCKER_PREFERRED_API_VERSION = "1.44"
CHUNK_SIZE = 1024 * 8 # 8KB
class Docker(DockerImagesMixin, BaseManager):
class Docker(BaseManager):
_NODE_CLASS = DockerVM
def __init__(self):
@@ -290,8 +291,7 @@ class Docker(DockerImagesMixin, BaseManager):
:param path: Endpoint in API
:param data: Dictionary with the body. Will be transformed to a JSON
:param params: Parameters added as a query arg
:param timeout: Total timeout in seconds, an aiohttp ClientTimeout object
for a finer policy (e.g. an idle socket read timeout), or None for no timeout
:param timeout: Timeout
:returns: HTTP response
"""
@@ -302,7 +302,7 @@ class Docker(DockerImagesMixin, BaseManager):
# not a dict (e.g. a Docker image tar stream): let aiohttp stream the raw body
headers = {"content-type": "application/x-tar"}
if timeout is None:
timeout = aiohttp.ClientTimeout(total=None)
timeout = 60 * 60 * 24 * 31 # One month timeout
if path == "version":
url = "http://docker/" + path
@@ -354,3 +354,177 @@ class Docker(DockerImagesMixin, BaseManager):
url = "http://docker/v" + self._api_version + "/" + path
connection = await self._session.ws_connect(url, origin="http://docker", autoping=True)
return connection
@locking
async def pull_image(self, image, progress_callback=None, force=False):
"""
Pulls an image from the Docker repository
:params image: Image name
:params progress_callback: A function that receive a log message about image download progress
:params force: Pull the image even if it is already available locally
"""
if not force:
try:
await self.query("GET", f"images/{image}/json")
return # We already have the image skip the download
except DockerHttp404Error:
pass
if progress_callback:
progress_callback(f"Pulling '{image}' from Docker repository")
try:
response = await self.http_query("POST", "images/create", params={"fromImage": image}, timeout=None)
except DockerError as e:
raise DockerError(
f"Could not pull the '{image}' image from Docker repository, "
f"please check your Internet connection (original error: {e})"
)
# The pull api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError(
f"Disconnected while pulling Docker image '{image}' from Docker repository"
) from e
except TimeoutError as e:
raise DockerError(f"Timeout while pulling Docker image '{image}' from Docker repository") from e
if not chunk:
break
content += chunk.decode("utf-8")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "progress" in answer and progress_callback:
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
finally:
response.close()
if progress_callback:
progress_callback(f"Success pulling image {image}")
@locking
async def load_image(self, stream, image=None, digest=None, progress_callback=None):
"""
Load a Docker image into the Docker daemon from a docker save tar stream
:param stream: An async iterable of bytes (the tar produced by docker save)
:param image: Tag of the image in the archive. Together with digest, the
(controller digest, local id) pair is remembered so that node creation
can accept the digest the controller pinned for this image
:param digest: Image id as reported by the Docker daemon on the controller
host (the value the controller pins in the node create payload)
:param progress_callback: A function that receive a log message about image load progress
"""
if progress_callback:
progress_callback("Loading Docker image from stream")
response = await self.http_query("POST", "images/load", data=stream, timeout=None)
# The load api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError("Disconnected while loading Docker image") from e
except TimeoutError as e:
raise DockerError("Timeout while loading Docker image") from e
if not chunk:
break
content += chunk.decode("utf-8", errors="ignore")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError("Invalid response while loading Docker image")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "stream" in answer and progress_callback:
progress_callback(answer["stream"].rstrip())
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError("Invalid response while loading Docker image")
finally:
response.close()
if progress_callback:
progress_callback("Docker image loaded")
if image and digest:
await self._remember_image_id_translation(image, digest)
async def _remember_image_id_translation(self, image, controller_digest):
"""
Remember how the local daemon identifies an image that was just loaded
from the controller.
Docker ids are not comparable across image stores: for the same content
a classic store reports the config digest, while a containerd store (or
an image loaded from an OCI archive) reports the manifest digest. The
controller pins the id of its own daemon, so recording the pair lets
DockerVM accept that pin for the image it actually received instead of
asking for another full sync on every node creation.
"""
try:
image_infos = await self.query("GET", f"images/{image}/json")
except DockerError as e:
log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}")
return
local_id = image_infos.get("Id")
if local_id:
self._image_id_translations[image] = (controller_digest, local_id)
def image_id_translation(self, image):
"""
:returns: the (controller digest, local id) pair recorded the last time
this image was loaded from the controller, None otherwise
"""
return self._image_id_translations.get(image)
async def list_images(self):
"""
Gets Docker image list.
:returns: list of dicts
:rtype: list
"""
images = []
for image in await self.query("GET", "images/json", params={"all": 0}):
if image["RepoTags"]:
for tag in image["RepoTags"]:
if tag != "<none>:<none>":
images.append({"image": tag})
return sorted(images, key=lambda i: i["image"])
-395
View File
@@ -1,395 +0,0 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the Free Software Foundation 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/>.
"""
The image-management half of the Docker manager: pull, load, list and
delete images, plus the translation between the id the controller pins and
the id this daemon reports for the same content. Consumed by the Docker
manager as a mixin, so __init__.py stays focused on the daemon connection,
the HTTP layer and the node factory.
"""
import json
import logging
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import aiohttp
from gns3server.compute.compute_error import ComputeError
from gns3server.compute.docker.docker_error import (
DockerError,
DockerHttp404Error,
DockerHttp409Error,
)
from gns3server.compute.project_manager import ProjectManager
from gns3server.utils.asyncio import locking
log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 8 # 8KB
# A pull streams progress from the daemon for its whole duration: when no data
# arrives for this many seconds the registry is unreachable and the pull would
# otherwise hang forever. This is an idle timeout, not a total time limit.
PULL_IDLE_TIMEOUT = 60
# Docker names GNS3 node containers "GNS3.{node name}.{project_id}", so a
# container name can be translated back into the node and project that block
# an image deletion. The node name may itself contain dots.
GNS3_CONTAINER_NAME_RE = re.compile(
r"^GNS3\.(?P<node>.+)\.(?P<project_id>[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12})$"
)
# The containers blocking an image deletion, as named by the daemon, e.g.
# 'container 6d59f00236ff is using its referenced image d529dd0c6e55'
CONFLICTING_CONTAINER_RE = re.compile(r"container ([0-9a-f]{12,64})\b")
# Images built for reproducibility, such as the Ubuntu images assembled with
# rockcraft, carry no created timestamp in their config and Docker reports 0
# (displayed as 1970-01-01). Their build tool writes the real time in this
# OCI label instead.
OCI_CREATED_LABEL = "org.opencontainers.image.created"
def image_created_timestamp(image: dict) -> int:
"""
Build time of an image as a Unix timestamp.
The daemon timestamp wins when it is set. When it is zero, fall back to
the OCI created label, if present.
:param image: an image summary as returned by the daemon images API
"""
created = image.get("Created") or 0
if created > 0:
return created
label = (image.get("Labels") or {}).get(OCI_CREATED_LABEL, "")
if not label:
return created
try:
built = datetime.fromisoformat(label)
except ValueError:
log.warning(f"Ignoring invalid {OCI_CREATED_LABEL} label: {label!r}")
return created
if built.tzinfo is None:
built = built.replace(tzinfo=UTC)
return int(built.timestamp())
class DockerImagesMixin:
"""
Image management for the Docker manager.
The host manager (Docker) provides the daemon connection: query(),
http_query() and the _image_id_translations dict filled in its __init__.
"""
if TYPE_CHECKING:
_image_id_translations: dict[str, tuple[str, str]]
def query(self, method: str, path: str, *args: Any, **kwargs: Any) -> Any: ...
def http_query(self, method: str, path: str, *args: Any, **kwargs: Any) -> Any: ...
@locking
async def pull_image(self, image, progress_callback=None, force=False):
"""
Pulls an image from the Docker repository
:params image: Image name
:params progress_callback: A function that receives a dict with the
overall progress of the pull: layer counts and the latest daemon
message. The first call carries state="started" and happens once
the pull owns the compute pull lock, so a caller queued behind
another pull only sees it when the pull really begins.
:params force: Pull the image even if it is already available locally
"""
layers_total = 0
layers_done = 0
def report(message, state=None):
if progress_callback:
progress = {"layers_total": layers_total, "layers_done": layers_done, "message": message}
if state is not None:
progress["state"] = state
progress_callback(progress)
report(f"Pulling '{image}' from Docker repository", state="started")
if not force:
try:
await self.query("GET", f"images/{image}/json")
return # We already have the image skip the download
except DockerHttp404Error:
pass
params = {"fromImage": image}
if "@" not in image:
# Without a tag or digest the daemon pulls every tag of the
# repository. Only a ':' after the last '/' is a tag separator:
# before it, it is a registry port (e.g. registry:5000/repo).
if ":" not in image.rpartition("/")[2]:
params["tag"] = "latest"
try:
# The daemon streams progress lines for the whole pull: an idle
# socket read for a minute means the registry is unreachable and
# the pull would otherwise hang forever.
response = await self.http_query(
"POST",
"images/create",
params=params,
timeout=aiohttp.ClientTimeout(total=None, sock_read=PULL_IDLE_TIMEOUT),
)
except DockerHttp404Error as e:
raise DockerError(
f"Could not pull the '{image}' image: it was not found in the registry. "
f"Check the image name or run 'docker login' on the compute host "
f"(original error: {e})"
)
except DockerError as e:
raise DockerError(
f"Could not pull the '{image}' image from Docker repository. "
f"Check the image name, the registry authentication and your Internet connection "
f"(original error: {e})"
)
# The pull api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError(
f"Disconnected while pulling Docker image '{image}' from Docker repository"
) from e
except TimeoutError as e:
raise DockerError(
f"Timeout while pulling Docker image '{image}' from Docker repository: "
f"no data received for {PULL_IDLE_TIMEOUT} seconds, "
f"please check your Internet connection or Docker registry mirror"
) from e
if not chunk:
break
content += chunk.decode("utf-8")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
status = answer.get("status", "")
if status == "Pulling fs layer":
layers_total += 1
elif status in ("Pull complete", "Already exists"):
layers_done += 1
message = answer.get("progress") or status
if message:
report(message)
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
finally:
response.close()
@locking
async def load_image(self, stream, image=None, digest=None, progress_callback=None):
"""
Load a Docker image into the Docker daemon from a docker save tar stream
:param stream: An async iterable of bytes (the tar produced by docker save)
:param image: Tag of the image in the archive. Together with digest, the
(controller digest, local id) pair is remembered so that node creation
can accept the digest the controller pinned for this image
:param digest: Image id as reported by the Docker daemon on the controller
host (the value the controller pins in the node create payload)
:param progress_callback: A function that receive a log message about image load progress
"""
if progress_callback:
progress_callback("Loading Docker image from stream")
response = await self.http_query("POST", "images/load", data=stream, timeout=None)
# The load api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError("Disconnected while loading Docker image") from e
except TimeoutError as e:
raise DockerError("Timeout while loading Docker image") from e
if not chunk:
break
content += chunk.decode("utf-8", errors="ignore")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError("Invalid response while loading Docker image")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "stream" in answer and progress_callback:
progress_callback(answer["stream"].rstrip())
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError("Invalid response while loading Docker image")
finally:
response.close()
if progress_callback:
progress_callback("Docker image loaded")
if image and digest:
await self._remember_image_id_translation(image, digest)
async def _remember_image_id_translation(self, image, controller_digest):
"""
Remember how the local daemon identifies an image that was just loaded
from the controller.
Docker ids are not comparable across image stores: for the same content
a classic store reports the config digest, while a containerd store (or
an image loaded from an OCI archive) reports the manifest digest. The
controller pins the id of its own daemon, so recording the pair lets
DockerVM accept that pin for the image it actually received instead of
asking for another full sync on every node creation.
"""
try:
image_infos = await self.query("GET", f"images/{image}/json")
except DockerError as e:
log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}")
return
local_id = image_infos.get("Id")
if local_id:
self._image_id_translations[image] = (controller_digest, local_id)
def image_id_translation(self, image):
"""
:returns: the (controller digest, local id) pair recorded the last time
this image was loaded from the controller, None otherwise
"""
return self._image_id_translations.get(image)
async def list_images(self):
"""
Gets Docker image list.
:returns: list of dicts
:rtype: list
"""
images = []
for image in await self.query("GET", "images/json", params={"all": 0}):
if image["RepoTags"]:
for tag in image["RepoTags"]:
if tag != "<none>:<none>":
images.append(
{
"image": tag,
"id": image.get("Id"),
"size": image.get("Size"),
"created": image_created_timestamp(image),
}
)
return sorted(images, key=lambda i: i["image"])
async def delete_image(self, image, force=False):
"""
Delete a Docker image from the Docker daemon.
When the daemon refuses because containers use the image, the error
names the GNS3 nodes and projects behind them instead of raw
container ids.
:param image: Image name or id
:param force: Remove the image even if a container uses it
"""
try:
await self.query("DELETE", f"images/{image}", params={"force": str(force).lower()})
except DockerHttp409Error as e:
blockers = await self._describe_containers_using_image(str(e))
if blockers:
raise DockerHttp409Error(
f"Cannot delete image '{image}': it is still used by {blockers}. "
f"Delete the node or container first, or repeat with force=true."
) from e
raise
# the image id translation recorded when this image was synced from the
# controller no longer matches anything on this daemon
self._image_id_translations.pop(image, None)
async def _describe_containers_using_image(self, daemon_error: str) -> str:
"""
Translate the container ids named in a daemon image deletion conflict
into node, project or container descriptions.
:param daemon_error: the raw daemon conflict message
:returns: a comma separated description, empty when the message names
no container
"""
descriptions = []
for container_id in CONFLICTING_CONTAINER_RE.findall(daemon_error):
try:
info = await self.query("GET", f"containers/{container_id}/json")
except DockerError:
info = None
if not info:
# the container cannot be inspected (or already vanished)
descriptions.append(f"container '{container_id}'")
continue
name = info.get("Name", container_id).lstrip("/")
state = "running" if info.get("State", {}).get("Running") else "stopped"
match = GNS3_CONTAINER_NAME_RE.match(name)
if match:
node = match.group("node")
try:
project = ProjectManager.instance().get_project(match.group("project_id"))
except ComputeError:
# the project is closed on this compute: a leftover container
descriptions.append(f"leftover container of node '{node}' ({state}, its project is closed)")
else:
descriptions.append(f"node '{node}' in project '{project.name}' ({state})")
else:
# a container created outside GNS3
descriptions.append(f"container '{name}' ({state})")
return ", ".join(descriptions)
+76 -18
View File
@@ -74,6 +74,10 @@ HYPERV_CLOCKSOURCES = ("hyperv_clocksource_tsc_page", "hyperv_clocksource_msr")
# guest CPU features turned off on such hosts
KVMCLOCK_OFF_FEATURES = ("kvmclock", "tsc-deadline")
PCIE_PLATFORMS = ("aarch64", "i386", "x86_64")
PCIE_ADAPTER_TYPES = ("e1000e", "igb", "virtio-net-pci", "vmxnet3")
MAX_PCIE_ADAPTERS = 232
def host_clocksource():
"""
@@ -2066,6 +2070,29 @@ class QemuVM(KernelDatapathMixin, BaseNode):
else:
return []
def _machine_options(self, additional_options):
if self.platform not in PCIE_PLATFORMS:
return []
for index, option in enumerate(additional_options):
if option in ("-machine", "-M") and index + 1 < len(additional_options):
for part in additional_options[index + 1].split(","):
machine_type = part.removeprefix("type=")
if "=" in machine_type:
continue
if self.platform == "aarch64":
compatible = machine_type == "virt" or machine_type.startswith("virt-")
else:
compatible = machine_type == "q35" or machine_type.startswith("pc-q35-")
if not compatible:
raise QemuError(
f"Machine type '{machine_type}' is incompatible with the PCIe network layout; "
f"use {'virt' if self.platform == 'aarch64' else 'q35'}"
)
if self.platform == "aarch64":
return self._arm64_options(shlex.join(additional_options))
return ["-machine", "q35"]
def _serial_options(self, internal_console_port, external_console_port):
if external_console_port:
@@ -2674,6 +2701,35 @@ class QemuVM(KernelDatapathMixin, BaseNode):
["-net", "none"]
) # we do not want any user networking back-end if no adapter is connected.
custom_adapters = [self._get_custom_adapter_settings(n) for n in range(self.adapters)]
adapter_types = [settings.get("adapter_type", self._adapter_type) for settings in custom_adapters]
pcie_adapters = {
n
for n, adapter_type in enumerate(adapter_types)
if self.platform in PCIE_PLATFORMS and adapter_type in PCIE_ADAPTER_TYPES
}
if len(pcie_adapters) > MAX_PCIE_ADAPTERS:
raise QemuError(f"At most {MAX_PCIE_ADAPTERS} PCIe network adapters are supported")
if self.platform in PCIE_PLATFORMS:
io_ports = sum(adapter_types[n] in ("e1000e", "igb") for n in pcie_adapters)
legacy_bridges = (self.adapters - len(pcie_adapters) + 31) // 32
if io_ports + legacy_bridges > 9:
raise QemuError(
"The network layout exceeds PCI I/O space: at most 9 I/O-bearing root ports "
"and legacy PCI bridge groups are supported in total; use virtio-net-pci or vmxnet3"
)
# Eight root-port functions per slot, leaving slots 0, 1 and 31 for platform devices.
for port_number, adapter_number in enumerate(sorted(pcie_adapters)):
slot, function = 2 + port_number // 8, port_number % 8
network_options.extend(
[
"-device",
f"pcie-root-port,id=gns3-pcie-{adapter_number},bus=pcie.0,"
f"addr=0x{slot:x}.{function},multifunction=on,chassis={port_number + 1},hotplug=off",
]
)
# Legacy PCI NICs share bridges to avoid exhausting the 64 KiB I/O space.
# Each 32 PCI device we need to add a PCI bridge with max 9 bridges
# Reserve 32 devices on root pci_bridge,
# since the number of devices used by templates may differ significantly
@@ -2687,8 +2743,8 @@ class QemuVM(KernelDatapathMixin, BaseNode):
nio = adapter.get_nio(0)
custom_adapter = self._get_custom_adapter_settings(adapter_number)
adapter_type = custom_adapter.get("adapter_type", self._adapter_type)
custom_adapter = custom_adapters[adapter_number]
adapter_type = adapter_types[adapter_number]
custom_mac_address = custom_adapter.get("mac_address")
if custom_mac_address:
mac = int_to_macaddress(macaddress_to_int(custom_mac_address))
@@ -2696,8 +2752,10 @@ class QemuVM(KernelDatapathMixin, BaseNode):
device_string = f"{adapter_type},mac={mac}"
if adapter_type == "virtio-net-pci":
device_string = f"{device_string},speed=10000,duplex=full"
bridge_id = math.floor(pci_device_id / 32)
if bridge_id > 0:
if adapter_number in pcie_adapters:
device_string = f"{device_string},bus=gns3-pcie-{adapter_number},addr=0x00"
else:
bridge_id = math.floor(pci_device_id / 32)
if pci_bridges_created < bridge_id:
network_options.extend(["-device", f"i82801b11-bridge,id=dmi_pci_bridge{bridge_id}"])
network_options.extend(
@@ -2709,7 +2767,7 @@ class QemuVM(KernelDatapathMixin, BaseNode):
pci_bridges_created += 1
addr = pci_device_id % 32
device_string = f"{device_string},bus=pci-bridge{bridge_id},addr=0x{addr:02x}"
pci_device_id += 1
pci_device_id += 1
# Every adapter is backed by its own TAP (created before launch,
# see _create_taps): the netdev is a kernel-side interface the
# link attaches to, not a UDP socket into uBridge. A legacy
@@ -2919,6 +2977,16 @@ class QemuVM(KernelDatapathMixin, BaseNode):
additional_options = additional_options.replace("%guest-cid%", str(self._guest_cid))
if self._console_type != "none" and self._console:
additional_options = additional_options.replace("%console-port%", str(self._console))
try:
additional_opt_list = shlex.split(additional_options)
except ValueError as e:
raise QemuError(f"Invalid additional options: {additional_options} error {e}")
allow_unsafe_options = self.manager.config.settings.Qemu.allow_unsafe_options
if allow_unsafe_options is False:
for opt in additional_opt_list:
if opt in FORBIDDEN_OPTIONS:
raise QemuError(f"Forbidden additional option: {opt}")
machine_options = self._machine_options(additional_opt_list)
command = [self.qemu_path]
command.extend(["-name", vm_name])
command.extend(["-m", f"{self._ram}M"])
@@ -2927,6 +2995,7 @@ class QemuVM(KernelDatapathMixin, BaseNode):
if self._cpus > maxcpus:
maxcpus = self._cpus
command.extend(["-smp", f"cpus={self._cpus},maxcpus={maxcpus},sockets=1"])
command.extend(machine_options)
kvm_enabled = False
if await self._run_with_hardware_acceleration(self.qemu_path, self._options):
command.extend(["-machine", "accel=kvm"])
@@ -2939,6 +3008,8 @@ class QemuVM(KernelDatapathMixin, BaseNode):
},
)
command.extend(["-boot", f"order={self._boot_priority}"])
# Reserve root-port addresses before other devices receive automatic PCI addresses.
command.extend(await self._network_options())
command.extend(self._cdrom_option())
command.extend(await self._disk_options())
command.extend(self._linux_boot_options())
@@ -2947,7 +3018,6 @@ class QemuVM(KernelDatapathMixin, BaseNode):
command.extend(self._console_options())
command.extend(self._aux_options())
command.extend(self._monitor_options())
command.extend(await self._network_options())
# bios options must be last to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838
command.extend(self._bios_option())
if self.on_close != "save_vm_state":
@@ -2958,18 +3028,6 @@ class QemuVM(KernelDatapathMixin, BaseNode):
command.extend(await self._disable_graphics())
if self._tpm:
command.extend(self._tpm_options())
additional_opt_list = []
if additional_options:
try:
additional_opt_list = shlex.split(additional_options)
except ValueError as e:
raise QemuError(f"Invalid additional options: {additional_options} error {e}")
allow_unsafe_options = self.manager.config.settings.Qemu.allow_unsafe_options
if allow_unsafe_options is False:
for opt in additional_opt_list:
if opt in FORBIDDEN_OPTIONS:
raise QemuError(f"Forbidden additional option: {opt}")
command.extend(self._arm64_options(additional_options))
if kvm_enabled:
kvmclock_off_reason = self._kvmclock_off_reason()
if kvmclock_off_reason:
+2 -2
View File
@@ -733,13 +733,13 @@ class Compute:
async def delete(self, path, **kwargs):
return await self.http_query("DELETE", path, **kwargs)
async def forward(self, method, type, path, data=None, params=None):
async def forward(self, method, type, path, data=None):
"""
Forward a call to the emulator on compute
"""
try:
action = f"/{type}/{path}"
res = await self.http_query(method, action, data=data, timeout=None, params=params)
res = await self.http_query(method, action, data=data, timeout=None)
except aiohttp.ServerDisconnectedError:
raise ControllerError(f"Connection lost to {self._id} during {method} {action}")
return res.json
-6
View File
@@ -126,12 +126,6 @@ class Notification:
elif action == "ping":
event["compute_id"] = compute_id
self.project_emit(action, event)
elif action.startswith("docker.image."):
# Image operations are scoped to a compute, not to a project, and
# clients that are not in a project still need them: emit on the
# controller stream, like compute.updated.
event["compute_id"] = compute_id
self.controller_emit(action, event)
elif action.startswith("marker."):
self.marker_emit(action, event, project_id)
else:
+3 -9
View File
@@ -2158,7 +2158,7 @@ class Project:
while self._loading:
await asyncio.sleep(0.5)
async def duplicate(self, name=None, reset_mac_addresses=True, user=None):
async def duplicate(self, name=None, reset_mac_addresses=True):
"""
Duplicate a project
@@ -2169,7 +2169,6 @@ class Project:
:param name: Name of the new project. A new one will be generated in case of conflicts
:param reset_mac_addresses: Reset MAC addresses for the new project
:param user: The user duplicating the project. The new project is owned by this user
"""
# If the project was not open we open it temporary
@@ -2181,7 +2180,7 @@ class Project:
assert self._status != "closed"
try:
proj = await self._fast_duplication(name, reset_mac_addresses=reset_mac_addresses, user=user)
proj = await self._fast_duplication(name, reset_mac_addresses)
if proj:
if previous_status == "closed":
await self.close()
@@ -2223,7 +2222,6 @@ class Project:
name=name,
reset_mac_addresses=reset_mac_addresses,
keep_compute_ids=True,
user=user,
)
log.info(f"Project '{project.name}' duplicated in {time.time() - begin:.4f} seconds")
@@ -2235,7 +2233,7 @@ class Project:
return project
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True, user=None):
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True):
"""
Fast duplication of a project.
@@ -2244,7 +2242,6 @@ class Project:
:param name: Name of the new project. A new one will be generated in case of conflicts
:param location: Parent directory of the new project
:param reset_mac_addresses: Reset MAC addresses for the duplicated project
:param user: The user duplicating the project. The new project is owned by this user
"""
# We don't duplicate a running project
@@ -2283,9 +2280,6 @@ class Project:
topology["auto_open"] = False
topology["auto_close"] = False
if user:
topology["created_by"] = user
# regenerate IDs for the duplicated project
regenerate_topology_ids(topology, new_project_path, reset_mac_addresses)
+1 -4
View File
@@ -27,7 +27,7 @@ from gns3server.compute.marker.marker_manager import MarkerManager
from gns3server.compute.port_manager import PortManager
from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.db.tasks import connect_to_db, disconnect_from_db, get_computes, load_rbac_state
from gns3server.db.tasks import connect_to_db, disconnect_from_db, get_computes
from gns3server.services.image_reconciliation import get_image_reconciliation_service
from gns3server.utils.http_client import HTTPClient
@@ -74,9 +74,6 @@ async def startup(app: FastAPI) -> None:
# retrieve the computes from the database
computes = await get_computes(app)
# load the in-memory RBAC snapshot before serving requests
await load_rbac_state(app)
await Controller.instance().start(computes)
get_image_reconciliation_service(app).start_background()
-399
View File
@@ -1,399 +0,0 @@
#
# 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/>.
#
"""
In-memory snapshot of the RBAC state.
The controller keeps its whole runtime state (projects, computes,
notifications) in process memory: a second controller instance is not a
supported setup. This module applies the same idea to the RBAC tables.
All roles, ACEs, group memberships and resource pool memberships are
loaded once into a process-wide snapshot, permission checks then run
against the snapshot instead of querying the database on every request.
The database stays the source of truth. Every repository method that
writes an RBAC table marks the snapshot as dirty, and the next read
rebuilds it from the database. Rebuilding is a handful of small SELECTs
and only happens after (human frequency) administrative writes, so the
checks keep their exact semantics while dropping from several queries
per request (including a full resource scan) to an in-memory lookup.
The evaluation methods replicate the semantics of the previous
database-backed implementation, quirks included (path reverse traversal,
deny raising PermissionError, pool checks with the "/pool" prefix, group
ACEs in natural order while user ACEs are sorted by descending path).
"""
import logging
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Set, Tuple
from urllib.parse import urlparse
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import gns3server.db.models as models
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class AceEntry:
"""
A flattened ACE: the ACE row itself plus the ids it matches.
"""
path: str
propagate: bool
allowed: bool
user_id: Optional[UUID]
group_id: Optional[UUID]
role_id: UUID
class RbacState:
"""
Process-wide snapshot of the RBAC tables.
The snapshot is loaded lazily on first use and rebuilt after any
RBAC write (mark_dirty). It must be reset when the database is
recreated (tests) via reset().
"""
_instance: Optional["RbacState"] = None
def __init__(self) -> None:
self._loaded: bool = False
self._dirty: bool = False
self._role_privileges: Dict[UUID, Set[str]] = {}
self._group_members: Dict[UUID, Set[UUID]] = {}
self._aces: List[AceEntry] = []
self._project_resource_ids: Set[str] = set()
# pool id (str) -> project resource ids (str)
self._pool_projects: Dict[str, Set[str]] = {}
@classmethod
def instance(cls) -> "RbacState":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def reset(self) -> None:
"""
Drop the snapshot: the next read reloads it from the database.
Called when the database is recreated (tests) or at startup.
"""
self._loaded = False
self._dirty = False
self._role_privileges = {}
self._group_members = {}
self._aces = []
self._project_resource_ids = set()
self._pool_projects = {}
def mark_dirty(self) -> None:
"""
Flag the snapshot as outdated. Repository write methods call
this after touching an RBAC table; no I/O is done here.
"""
self._dirty = True
async def reload(self, db_session: AsyncSession) -> None:
"""
Rebuild the snapshot from the database, whatever its state.
"""
# clear the flag before loading: if a write marks the snapshot
# dirty again while the queries are awaited, the next read
# triggers another reload
self._dirty = False
# role id -> privilege names
roles_result = await db_session.execute(select(models.Role).options(selectinload(models.Role.privileges)))
role_privileges: Dict[UUID, Set[str]] = {}
for role in roles_result.scalars().all():
role_privileges[role.role_id] = {privilege.name for privilege in role.privileges}
# group id -> member user ids
result = await db_session.execute(select(models.UserGroup).options(selectinload(models.UserGroup.users)))
group_members: Dict[UUID, Set[UUID]] = {}
for group in result.scalars().all():
group_members[group.user_group_id] = {user.user_id for user in group.users}
# ACEs, in natural database order (group ACE evaluation order relies on it)
aces_result = await db_session.execute(select(models.ACE))
aces: List[AceEntry] = []
for ace in aces_result.scalars().all():
# ACE uses legacy Column declarations, so typed attribute access
# on instances is not available: go through the plain dict
values = ace.asdict()
aces.append(
AceEntry(
path=values["path"],
propagate=values["propagate"],
allowed=values["allowed"],
user_id=values["user_id"],
group_id=values["group_id"],
role_id=values["role_id"],
)
)
# all registered project resources, pooled or not: the pool gate in
# user_has_privilege checks paths against every project resource
resources_result = await db_session.execute(
select(models.Resource)
.options(selectinload(models.Resource.resource_pools))
.filter(models.Resource.resource_type == "project")
)
project_resource_ids: Set[str] = set()
pool_projects: Dict[str, Set[str]] = {}
for resource in resources_result.scalars().all():
project_resource_ids.add(str(resource.resource_id))
for pool in resource.resource_pools:
pool_projects.setdefault(str(pool.resource_pool_id), set()).add(str(resource.resource_id))
self._role_privileges = role_privileges
self._group_members = group_members
self._aces = aces
self._project_resource_ids = project_resource_ids
self._pool_projects = pool_projects
self._loaded = True
log.debug(f"RBAC snapshot reloaded: {len(aces)} ACE(s), {len(role_privileges)} role(s)")
async def ensure_loaded(self, db_session: AsyncSession) -> None:
"""
Rebuild the snapshot if it has never been loaded or is dirty.
"""
if not self._loaded or self._dirty:
await self.reload(db_session)
#
# Evaluation methods below are pure: no database access.
#
def _user_aces(self, user_id: UUID, privilege_name: str) -> List[AceEntry]:
"""
ACEs assigned to the user itself granting the privilege,
sorted by descending path (same order as the SQL implementation).
"""
aces = [
ace
for ace in self._aces
if ace.user_id == user_id and privilege_name in self._role_privileges.get(ace.role_id, ())
]
return sorted(aces, key=lambda ace: ace.path, reverse=True)
def _group_aces(self, user_id: UUID, privilege_name: str) -> List[AceEntry]:
"""
ACEs assigned to the groups the user belongs to granting the
privilege, in natural order (same order as the SQL implementation).
"""
groups = {group_id for group_id, members in self._group_members.items() if user_id in members}
return [
ace
for ace in self._aces
if ace.group_id is not None
and ace.group_id in groups
and privilege_name in self._role_privileges.get(ace.role_id, ())
]
@staticmethod
def _check_path_with_aces(path: str, aces: Iterable[AceEntry]) -> bool:
"""
Compare path with existing ACEs to check if the user has the required privilege on that path.
"""
parsed_url = urlparse(path)
original_path = path
path_components = parsed_url.path.split("/")
# traverse the path in reverse order
for i in range(len(path_components), 0, -1):
path = "/".join(path_components[:i])
if not path:
path = "/"
for ace in aces:
if ace.path == path:
if not ace.allowed:
raise PermissionError(f"Permission denied for {path}")
if path == original_path or ace.propagate:
return True # only allow if the path is the original path or the ACE is set to propagate
return False
def _pool_grants_path(self, aces: Iterable[AceEntry], path: str) -> bool:
"""
Whether any pool referenced by the given ACEs contains a project
matching the path (the previous implementation returned the list
of matching resources; only its truthiness was used).
"""
for ace in aces:
if ace.path.startswith("/pool"):
pool_id = ace.path.split("/")[2]
for project_id in self._pool_projects.get(pool_id, ()):
if path.startswith(f"/projects/{project_id}"):
return True
return False
def user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
"""
Check the user has a privilege on a path.
Resource paths form a file system like tree and privileges can be inherited by paths down that tree
(the propagate field is True by default)
The following inheritance rules are used:
* Privileges for individual users always replace group privileges.
* Privileges for groups apply when the user is member of that group.
* Privileges on deeper levels replace those inherited from an upper level.
"""
path_is_in_pool = any(path.startswith(f"/projects/{resource_id}") for resource_id in self._project_resource_ids)
user_aces = self._user_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, user_aces):
# the user has an ACE matching the path and privilege, there is no need to check group ACEs
return True
# Then check resource pool ACEs
if path_is_in_pool:
if self._pool_grants_path(user_aces, path):
return True
except PermissionError:
return False
group_aces = self._group_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, group_aces):
return True
# Then check resource pool ACEs
if path_is_in_pool:
if self._pool_grants_path(group_aces, path):
return True
except PermissionError:
return False
return False
def user_privilege_names(self, user_id: UUID) -> Set[str]:
"""
Names of all privileges granted to a user through its roles, via
ACEs on the user itself and on the groups the user belongs to.
Flattened view that ignores resource paths.
"""
groups = {group_id for group_id, members in self._group_members.items() if user_id in members}
names: Set[str] = set()
for ace in self._aces:
if not ace.allowed:
continue
if ace.user_id == user_id or (ace.group_id is not None and ace.group_id in groups):
names.update(self._role_privileges.get(ace.role_id, ()))
return names
def _direct_project_ids(
self, user_aces: List[AceEntry], group_aces: List[AceEntry], all_project_ids: List[str]
) -> Tuple[Set[str], Set[str]]:
"""
Projects the user can access via a direct ACE on /projects/<id>:
user ACEs first (a deny puts the project aside as denied), then
group ACEs skipping already accessible or denied projects.
"""
direct_ace_ids = set()
user_denied = set()
# --- User ACE: direct path check ---
for pid in all_project_ids:
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, user_aces):
direct_ace_ids.add(pid)
except PermissionError:
user_denied.add(pid)
# --- Group ACE: direct path check (skip already accessible or denied) ---
for pid in all_project_ids:
if pid in direct_ace_ids or pid in user_denied:
continue
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, group_aces):
direct_ace_ids.add(pid)
except PermissionError:
pass
return direct_ace_ids, user_denied
def _pooled_project_ids(
self, user_aces: List[AceEntry], group_aces: List[AceEntry], user_denied: Set[str]
) -> Set[str]:
"""
Projects the user can access because they sit in a resource pool
referenced by an allowed ACE: user pools first, then group pools.
"""
# --- User ACE: pool check ---
user_pool_ids = {ace.path.split("/")[2] for ace in user_aces if ace.path.startswith("/pools/") and ace.allowed}
pool_accessible_ids = set()
for pool_id, project_ids in self._pool_projects.items():
if pool_id in user_pool_ids:
for pid in project_ids:
if pid not in user_denied:
pool_accessible_ids.add(pid)
# --- Group ACE: pool check ---
group_pool_ids = {
ace.path.split("/")[2] for ace in group_aces if ace.path.startswith("/pools/") and ace.allowed
}
for pool_id, project_ids in self._pool_projects.items():
if pool_id in group_pool_ids:
for pid in project_ids:
if pid not in user_denied and pid not in pool_accessible_ids:
pool_accessible_ids.add(pid)
return pool_accessible_ids
def accessible_project_ids(
self, user_id: UUID, privilege_name: str, all_project_ids: List[str]
) -> Tuple[Set[str], Set[str]]:
"""
Batch check which projects a user can access via direct ACE or resource pools.
Returns:
(direct_ace_ids, pool_accessible_ids) where:
- direct_ace_ids: projects the user has a direct ACE on (needs created_by filter)
- pool_accessible_ids: projects in resource pools the user can access (no created_by filter)
"""
user_aces = self._user_aces(user_id, privilege_name)
group_aces = self._group_aces(user_id, privilege_name)
direct_ace_ids, user_denied = self._direct_project_ids(user_aces, group_aces, all_project_ids)
pool_accessible_ids = self._pooled_project_ids(user_aces, group_aces, user_denied)
return direct_ace_ids, pool_accessible_ids
-9
View File
@@ -26,7 +26,6 @@ from sqlalchemy.orm import selectinload
import gns3server.db.models as models
from gns3server import schemas
from gns3server.db.rbac_state import RbacState
from .base import BaseRepository
@@ -37,7 +36,6 @@ class ResourcePoolsRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
self._rbac_state = RbacState.instance()
async def get_resource(self, resource_id: UUID) -> Optional[models.Resource]:
"""
@@ -67,7 +65,6 @@ class ResourcePoolsRepository(BaseRepository):
)
self._db_session.add(db_resource)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_resource)
return db_resource
@@ -79,7 +76,6 @@ class ResourcePoolsRepository(BaseRepository):
query = delete(models.Resource).where(models.Resource.resource_id == resource_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def get_resource_memberships(self, resource_id: UUID) -> List[models.ResourcePool]:
@@ -131,7 +127,6 @@ class ResourcePoolsRepository(BaseRepository):
db_resource_pool = models.ResourcePool(name=resource_pool.name)
self._db_session.add(db_resource_pool)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_resource_pool)
return db_resource_pool
@@ -151,7 +146,6 @@ class ResourcePoolsRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
resource_pool_db = await self.get_resource_pool(resource_pool_id)
if resource_pool_db:
await self._db_session.refresh(resource_pool_db) # force refresh of updated_at value
@@ -173,7 +167,6 @@ class ResourcePoolsRepository(BaseRepository):
query = delete(models.ResourcePool).where(models.ResourcePool.resource_pool_id == resource_pool_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def add_resource_to_pool(
@@ -195,7 +188,6 @@ class ResourcePoolsRepository(BaseRepository):
resource_pool_db.resources.append(resource)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(resource_pool_db)
return resource_pool_db
@@ -218,7 +210,6 @@ class ResourcePoolsRepository(BaseRepository):
resource_pool_db.resources.remove(resource)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(resource_pool_db)
return resource_pool_db
+202 -33
View File
@@ -16,17 +16,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from typing import List, Optional, Set, Union, cast
from typing import Any, List, Optional, Union, cast
from urllib.parse import urlparse
from uuid import UUID
from sqlalchemy import delete, select, update
from sqlalchemy import Select, delete, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import gns3server.db.models as models
from gns3server import schemas
from gns3server.db.rbac_state import RbacState
from .base import BaseRepository
@@ -37,7 +37,6 @@ class RbacRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
self._rbac_state = RbacState.instance()
async def get_role(self, role_id: UUID) -> Optional[models.Role]:
"""
@@ -77,7 +76,6 @@ class RbacRepository(BaseRepository):
)
self._db_session.add(db_role)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return await self.get_role(db_role.role_id)
async def update_role(self, role_id: UUID, role_update: schemas.RoleUpdate) -> Optional[models.Role]:
@@ -90,7 +88,6 @@ class RbacRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
role_db = await self.get_role(role_id)
if role_db:
await self._db_session.refresh(role_db) # force refresh of updated_at value
@@ -104,7 +101,6 @@ class RbacRepository(BaseRepository):
query = delete(models.Role).where(models.Role.role_id == role_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def add_privilege_to_role(self, role_id: UUID, privilege: models.Privilege) -> Union[None, models.Role]:
@@ -127,7 +123,6 @@ class RbacRepository(BaseRepository):
role_db.privileges.append(privilege)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(role_db)
return role_db
@@ -144,7 +139,6 @@ class RbacRepository(BaseRepository):
role_db.privileges.remove(privilege)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(role_db)
return role_db
@@ -185,18 +179,6 @@ class RbacRepository(BaseRepository):
result = await self._db_session.execute(query)
return list(result.scalars().all())
async def get_user_privileges(self, user_id: UUID) -> Set[str]:
"""
Get the names of all privileges granted to a user through its roles,
via ACEs on the user itself and on the groups the user belongs to.
This is a flattened view that ignores resource paths: the authoritative
check remains check_user_has_privilege(), which takes paths into account.
"""
await self._rbac_state.ensure_loaded(self._db_session)
return self._rbac_state.user_privilege_names(user_id)
async def get_ace(self, ace_id: UUID) -> Optional[models.ACE]:
"""
Get an ACE by its ID.
@@ -257,7 +239,6 @@ class RbacRepository(BaseRepository):
db_ace = models.ACE(**create_values)
self._db_session.add(db_ace)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_ace)
return db_ace
@@ -271,7 +252,6 @@ class RbacRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
ace_db = await self.get_ace(ace_id)
if ace_db:
await self._db_session.refresh(ace_db) # force refresh of updated_at value
@@ -285,7 +265,6 @@ class RbacRepository(BaseRepository):
query = delete(models.ACE).where(models.ACE.ace_id == ace_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def delete_all_ace_starting_with_path(self, path: str) -> None:
@@ -295,13 +274,109 @@ class RbacRepository(BaseRepository):
query = delete(models.ACE).where(models.ACE.path.startswith(path)).execution_options(synchronize_session=False)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
log.debug(f"{cast(CursorResult, result).rowcount} ACE(s) have been deleted")
@staticmethod
def _check_path_with_aces(path: str, aces) -> bool:
"""
Compare path with existing ACEs to check if the user has the required privilege on that path.
"""
parsed_url = urlparse(path)
original_path = path
path_components = parsed_url.path.split("/")
# traverse the path in reverse order
for i in range(len(path_components), 0, -1):
path = "/".join(path_components[:i])
if not path:
path = "/"
for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
if ace_path == path:
if not ace_allowed:
raise PermissionError(f"Permission denied for {path}")
if path == original_path or ace_propagate:
return True # only allow if the path is the original path or the ACE is set to propagate
return False
async def _get_resources_in_pools(self, aces, path: Optional[str] = None) -> List[models.Resource]:
"""
Get all resources in pools.
"""
pool_resources = []
for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
if ace_path.startswith("/pool"):
resource_pool_id = ace_path.split("/")[2]
query = (
select(models.Resource)
.join(models.Resource.resource_pools)
.filter(models.ResourcePool.resource_pool_id == resource_pool_id)
)
result = await self._db_session.execute(query)
resources = result.scalars().all()
for resource in resources:
# we only support projects in resource pools for now
if resource.resource_type == "project":
if path:
if path.startswith(f"/projects/{resource.resource_id}"):
pool_resources.append(resource)
else:
pool_resources.append(resource)
return pool_resources
async def _get_user_aces(self, user_id: UUID, privilege_name: str):
"""
Retrieve all user ACEs matching the user_id and privilege name.
"""
query: Select[Any, Any, Any, Any] = (
select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles)
.join(models.Role.acl_entries)
.join(models.ACE.user)
.filter(models.User.user_id == user_id)
.filter(models.Privilege.name == privilege_name)
.order_by(models.ACE.path.desc())
)
result = await self._db_session.execute(query)
return result.all()
async def _get_group_aces(self, user_id: UUID, privilege_name: str):
"""
Retrieve all group ACEs matching the user_id and privilege name.
"""
query: Select[Any, Any, Any, Any] = (
select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles)
.join(models.Role.acl_entries)
.join(models.ACE.group)
.join(models.UserGroup.users)
.filter(models.User.user_id == user_id)
.filter(models.Privilege.name == privilege_name)
)
result = await self._db_session.execute(query)
return result.all()
async def get_user_pool_resources(self, user_id: UUID, privilege_name: str) -> List[models.Resource]:
"""
Get all resources in pools belonging to a user and groups
"""
user_aces = await self._get_user_aces(user_id, privilege_name)
pool_resources = await self._get_resources_in_pools(user_aces)
group_aces = await self._get_group_aces(user_id, privilege_name)
pool_resources.extend(await self._get_resources_in_pools(group_aces))
return list(set(pool_resources))
async def get_accessible_project_ids(self, user_id: UUID, privilege_name: str, all_project_ids: List[str]):
"""
Batch check which projects a user can access via direct ACE or resource pools.
Performs 3 fixed DB queries regardless of project count.
Returns:
(direct_ace_ids, pool_accessible_ids) where:
@@ -309,8 +384,72 @@ class RbacRepository(BaseRepository):
- pool_accessible_ids: projects in resource pools the user can access (no created_by filter)
"""
await self._rbac_state.ensure_loaded(self._db_session)
return self._rbac_state.accessible_project_ids(user_id, privilege_name, all_project_ids)
user_aces = await self._get_user_aces(user_id, privilege_name)
group_aces = await self._get_group_aces(user_id, privilege_name)
# Single query for all resources and their pool memberships
query = select(models.Resource).options(selectinload(models.Resource.resource_pools))
result = await self._db_session.execute(query)
all_resources = result.scalars().all()
# Precompute pool_id -> set of project_ids
pool_to_projects: dict[str, set[str]] = {}
for r in all_resources:
if r.resource_type == "project":
for pool in r.resource_pools:
pool_to_projects.setdefault(str(pool.resource_pool_id), set()).add(str(r.resource_id))
# --- User ACE: direct path check ---
direct_ace_ids = set()
user_denied = set()
for pid in all_project_ids:
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, user_aces):
direct_ace_ids.add(pid)
except PermissionError:
user_denied.add(pid)
# --- User ACE: pool check ---
user_pool_ids = {
ace_path.split("/")[2]
for ace_path, _, ace_allowed, _ in user_aces
if ace_path.startswith("/pools/") and ace_allowed
}
pool_accessible_ids = set()
for pool_id, project_ids in pool_to_projects.items():
if pool_id in user_pool_ids:
for pid in project_ids:
if pid not in user_denied:
pool_accessible_ids.add(pid)
# --- Group ACE: direct path check (skip already accessible or denied) ---
for pid in all_project_ids:
if pid in direct_ace_ids or pid in user_denied:
continue
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, group_aces):
direct_ace_ids.add(pid)
except PermissionError:
pass
# --- Group ACE: pool check ---
group_pool_ids = {
ace_path.split("/")[2]
for ace_path, _, ace_allowed, _ in group_aces
if ace_path.startswith("/pools/") and ace_allowed
}
for pool_id, project_ids in pool_to_projects.items():
if pool_id in group_pool_ids:
for pid in project_ids:
if pid not in user_denied and pid not in pool_accessible_ids:
pool_accessible_ids.add(pid)
return direct_ace_ids, pool_accessible_ids
async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
"""
@@ -322,10 +461,40 @@ class RbacRepository(BaseRepository):
* Privileges for individual users always replace group privileges.
* Privileges for groups apply when the user is member of that group.
* Privileges on deeper levels replace those inherited from an upper level.
The check runs against the in-memory RBAC snapshot (rebuilt after
any RBAC write), see gns3server.db.rbac_state.
"""
await self._rbac_state.ensure_loaded(self._db_session)
return self._rbac_state.user_has_privilege(user_id, path, privilege_name)
query = select(models.Resource)
result = await self._db_session.execute(query)
resources = result.scalars().all()
projects_in_pools = [f"/projects/{r.resource_id}" for r in resources if r.resource_type == "project"]
path_is_in_pool = False
for project_in_pool in projects_in_pools:
if path.startswith(project_in_pool):
path_is_in_pool = True
break
aces = await self._get_user_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, aces):
# the user has an ACE matching the path and privilege, there is no need to check group ACEs
return True
# Then check resource pool ACEs
if path_is_in_pool:
if await self._get_resources_in_pools(aces, path):
return True
except PermissionError:
return False
aces = await self._get_group_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, aces):
return True
# Then check resource pool ACEs
if path_is_in_pool:
if await self._get_resources_in_pools(aces, path):
return True
except PermissionError:
return False
return False
-10
View File
@@ -26,7 +26,6 @@ from sqlalchemy.orm import selectinload
import gns3server.db.models as models
from gns3server import schemas
from gns3server.db.rbac_state import RbacState
from gns3server.services import auth_service
from .base import BaseRepository
@@ -39,7 +38,6 @@ class UsersRepository(BaseRepository):
super().__init__(db_session)
self._auth_service = auth_service
self._rbac_state = RbacState.instance()
async def get_user(self, user_id: UUID) -> Optional[models.User]:
"""
@@ -88,7 +86,6 @@ class UsersRepository(BaseRepository):
)
self._db_session.add(db_user)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_user)
return db_user
@@ -108,7 +105,6 @@ class UsersRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
user_db = await self.get_user(user_id)
if user_db:
await self._db_session.refresh(user_db) # force refresh of updated_at value
@@ -136,7 +132,6 @@ class UsersRepository(BaseRepository):
query = delete(models.User).where(models.User.user_id == user_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def authenticate_user(self, username: str, password: str) -> Optional[models.User]:
@@ -213,7 +208,6 @@ class UsersRepository(BaseRepository):
db_user_group = models.UserGroup(name=user_group.name)
self._db_session.add(db_user_group)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_user_group)
return db_user_group
@@ -229,7 +223,6 @@ class UsersRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
user_group_db = await self.get_user_group(user_group_id)
if user_group_db:
await self._db_session.refresh(user_group_db) # force refresh of updated_at value
@@ -243,7 +236,6 @@ class UsersRepository(BaseRepository):
query = delete(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def add_member_to_user_group(self, user_group_id: UUID, user: models.User) -> Union[None, models.UserGroup]:
@@ -263,7 +255,6 @@ class UsersRepository(BaseRepository):
user_group_db.users.append(user)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(user_group_db)
return user_group_db
@@ -286,7 +277,6 @@ class UsersRepository(BaseRepository):
user_group_db.users.remove(user)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(user_group_db)
return user_group_db
-11
View File
@@ -32,7 +32,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from gns3server import schemas
from gns3server.config import Config
from gns3server.db.rbac_state import RbacState
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.images import ImagesRepository
from gns3server.utils.images import read_image_info
@@ -200,16 +199,6 @@ async def get_computes(app: FastAPI) -> List[schemas.Compute]:
return computes
async def load_rbac_state(app: FastAPI) -> None:
"""
Load the in-memory RBAC snapshot at startup, so permission checks do
not hit the database on the request path.
"""
async with AsyncSession(app.state._db_engine) as db_session:
await RbacState.instance().reload(db_session)
async def update_disk_checksums(updated_disks: List[str]) -> None:
"""Refresh complete metadata after a server-managed disk modification."""
from gns3server.api.server import app
-4
View File
@@ -71,7 +71,6 @@ from .controller.projects import (
ProjectCreate,
ProjectDuplicate,
ProjectFile,
ProjectPrivileges,
ProjectStats,
ProjectUpdate,
)
@@ -84,7 +83,6 @@ from .controller.users import (
UserGroup,
UserGroupCreate,
UserGroupUpdate,
UserPrivileges,
UserUpdate,
)
from .version import Version
@@ -352,7 +350,6 @@ __all__ = [
"ProjectDuplicate",
"ProjectFile",
"ProjectMarker",
"ProjectPrivileges",
"ProjectStats",
"ProjectUpdate",
"Qemu",
@@ -400,7 +397,6 @@ __all__ = [
"UserGroup",
"UserGroupCreate",
"UserGroupUpdate",
"UserPrivileges",
"UserUpdate",
"VMware",
"VMwareCreate",
@@ -146,9 +146,6 @@ class ComputeDockerImage(BaseModel):
"""
image: str = Field(..., description="Docker image name")
id: Optional[str] = Field(None, description="Docker image id reported by the daemon")
size: Optional[int] = Field(None, description="Docker image size in bytes")
created: Optional[Union[int, str]] = Field(None, description="Docker image creation date (unix timestamp)")
class AutoIdlePC(BaseModel):
-15
View File
@@ -139,18 +139,3 @@ class ProjectStats(BaseModel):
drawings: int
snapshots: int
markers: int
class ProjectPrivileges(BaseModel):
"""
Effective privileges of the logged in user on a single project, evaluated
on the project's resource path (so ACEs scoped to that project and
resource pools containing it are reflected).
This is a hint for rendering purposes only (e.g. showing or hiding buttons
in the project view): the server remains the authority for permission
checks on each request.
"""
is_superadmin: bool
privileges: List[str]
+1 -15
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime
from typing import List, Optional
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, SecretStr
@@ -69,20 +69,6 @@ class User(DateTimeModelMixin, UserBase):
model_config = ConfigDict(from_attributes=True)
class UserPrivileges(BaseModel):
"""
Effective privileges of the logged in user, flattened across all roles
assigned to the user (directly or through group membership).
This is a hint for rendering purposes only: the server remains the
authority for permission checks and can still deny a privilege listed
here on a specific resource path.
"""
is_superadmin: bool
privileges: List[str]
class UserGroupBase(BaseModel):
"""
Common user group properties.
+9 -25
View File
@@ -42,12 +42,6 @@ from gns3server.version import __version__
log = logging.getLogger(__name__)
# Uvicorn waits for open connections forever by default. A long-lived
# streaming response (for example the MCP GET/SSE stream) would then block
# Ctrl+C indefinitely. Bound the wait so remaining tasks are cancelled and
# the lifespan shutdown (controller and project cleanup) always runs.
GRACEFUL_SHUTDOWN_TIMEOUT = 10
class Server:
_stream_handler = None
@@ -228,24 +222,6 @@ class Server:
log.critical("Can't write pid file %s: %s", path, str(e))
sys.exit(1)
@staticmethod
def _create_uvicorn_config(app, host: str, port: int, access_log: bool) -> uvicorn.Config:
"""
Build the uvicorn configuration.
"""
server_config = Config.instance().settings.Server
return uvicorn.Config(
app,
host=host,
port=port,
access_log=access_log,
ssl_certfile=server_config.certfile,
ssl_keyfile=server_config.certkey,
lifespan="on",
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
async def run(self, parser, args):
self._setup_logging(args)
@@ -325,7 +301,15 @@ class Server:
if config.Server.enable_ssl:
log.info("SSL is enabled")
config = self._create_uvicorn_config(app, host, port, access_log)
config = uvicorn.Config(
app,
host=host,
port=port,
access_log=access_log,
ssl_certfile=config.Server.certfile,
ssl_keyfile=config.Server.certkey,
lifespan="on",
)
# overwrite uvicorn loggers with our own logger
for uvicorn_logger_name in ("uvicorn", "uvicorn.error"):
+1 -27
View File
@@ -47,34 +47,8 @@ def utcnow():
return datetime.now(UTC).replace(tzinfo=None)
# Files that are never images: scripts, documentation and certificates users
# keep next to their images (e.g. the Cisco IOU keygen script). Skipping them
# keeps them out of the catalog and stops them from triggering rescans.
IGNORED_EXTENSIONS = {
".cfg",
".conf",
".crt",
".json",
".key",
".lic",
".license",
".log",
".md",
".pem",
".py",
".sh",
".txt",
".yaml",
".yml",
}
def ignored(name):
return (
name.startswith(".")
or name.endswith((".tmp", ".md5sum"))
or os.path.splitext(name)[1].lower() in IGNORED_EXTENSIONS
)
return name.startswith(".") or name.endswith((".tmp", ".md5sum"))
def configured_roots():
-56
View File
@@ -53,62 +53,6 @@ log = logging.getLogger(__name__)
# Include the first ISO 9660 volume descriptor in image type detection.
IMAGE_HEADER_SIZE = 16 * 2048 + 7
# A Docker image reference: name, optional registry, tag and digest. Callers
# must also reject ".." (not expressible here, the pydantic regex engine has no
# lookaround) so a name cannot escape the daemon API path it is interpolated
# into: see validate_docker_image_name.
DOCKER_IMAGE_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$"
def validate_docker_image_name(image: str) -> None:
"""
Reject Docker image names that could escape the daemon API path they are
interpolated into.
:raises ValueError: when the name is not safe
"""
if ".." in image:
raise ValueError(f"Invalid Docker image name: {image}")
def canonical_docker_image_name(image: str) -> str:
"""
Normalize a Docker image reference for name comparisons.
Registry hosts are dropped, Docker Hub's implicit "library/" namespace and
the implicit ":latest" tag are made explicit, so "alpine",
"library/alpine:latest" and "docker.io/library/alpine" all normalize to
the same string. A reference pinned by digest keeps its digest and loses
its tag.
"""
reference = image.strip()
if "/" in reference:
registry, _, remainder = reference.partition("/")
if registry == "localhost" or "." in registry or ":" in registry:
# the first path element is a registry host, not a namespace
reference = remainder
if "@" in reference:
repository, _, digest = reference.rpartition("@")
if not repository:
return reference # a bare digest, nothing to normalize
# with a digest pinned the tag does not take part in the identity
repository = repository.partition(":")[0]
return f"{_canonical_docker_repository(repository)}@{digest}"
repository, separator, tag = reference.rpartition(":")
if not separator or not repository:
repository, tag = reference, "latest"
return f"{_canonical_docker_repository(repository)}:{tag or 'latest'}"
def _canonical_docker_repository(repository: str) -> str:
if "/" not in repository:
# images without a namespace live in Docker Hub's "library/" namespace
return f"library/{repository}"
return repository
async def list_images(image_type):
"""
+1 -2
View File
@@ -180,8 +180,7 @@ extend-immutable-calls = [
"gns3server/api/routes/controller/templates.py" = ["S324"]
"gns3server/api/routes/controller/users.py" = ["S105", "S106"]
"gns3server/compute/base_node.py" = ["C901", "RUF019"]
"gns3server/compute/docker/__init__.py" = ["B006"]
"gns3server/compute/docker/docker_images.py" = ["C901"]
"gns3server/compute/docker/__init__.py" = ["B006", "C901"]
"gns3server/compute/docker/docker_vm.py" = ["B006", "C901", "E731", "RUF003", "RUF006", "S108"]
"gns3server/compute/docker/iol_docker_vm.py" = ["RUF002", "S108"]
"gns3server/compute/docker/vendor_docker_vm.py" = ["RUF002", "S108"]
+2 -2
View File
@@ -3,7 +3,7 @@ uvicorn==0.54.0
pydantic==2.14.2
fastapi==0.162.0
python-multipart==0.0.33
websockets==17.4
websockets==16.1.1 # google-genai 2.10.0 has requirement websockets<17.0
aiohttp>=3.14.5,<3.15
aiofiles>=25.1.0,<26.0
Jinja2>=3.1.6,<3.2
@@ -11,7 +11,7 @@ sentry-sdk>=2.73.0,<3 # optional dependency
psutil>=7.2.2
distro>=1.9.0
py-cpuinfo>=9.0.0,<10.0
sqlalchemy[asyncio]==2.1.8
sqlalchemy[asyncio]==2.1.6
aiosqlite==0.22.1
alembic==1.20.1
bcrypt==5.0.0
+5 -96
View File
@@ -18,7 +18,6 @@ import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.compute.docker.docker_error import DockerError
from tests.utils import asyncio_patch
pytestmark = pytest.mark.asyncio
@@ -27,75 +26,12 @@ pytestmark = pytest.mark.asyncio
class TestImagesRoutes:
async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
progress_callback(
{"layers_total": 0, "layers_done": 0, "message": "Pulling 'nginx:latest'", "state": "started"}
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull) as mock:
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
assert mock.call_args[0] == ("nginx:latest",)
assert mock.call_args[1]["force"] is True
assert callable(mock.call_args[1]["progress_callback"])
# started and completed events are emitted to the notification stream
assert [(c.args[0], c.args[1]["state"]) for c in emit_mock.call_args_list] == [
("docker.image.pull", "started"),
("docker.image.pull", "completed"),
]
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_pull_docker_image_progress_id(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
progress_callback({"layers_total": 1, "layers_done": 0, "message": "Pulling", "state": "started"})
progress_callback({"layers_total": 1, "layers_done": 0, "message": "[==> ] 10/100"})
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull):
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"),
json={"image": "nginx:latest"},
params={"progress_id": "abc123"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert all(c.args[1]["progress_id"] == "abc123" for c in emit_mock.call_args_list)
async def test_pull_docker_image_reports_downloading(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
progress_callback({"layers_total": 2, "layers_done": 0, "message": "Pulling", "state": "started"})
progress_callback({"layers_total": 2, "layers_done": 1, "message": "[==> ] 40/100"})
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull):
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
states = [c.args[1]["state"] for c in emit_mock.call_args_list]
assert states == ["started", "downloading", "completed"]
downloading = emit_mock.call_args_list[1].args[1]
assert downloading["layers_total"] == 2
assert downloading["layers_done"] == 1
assert downloading["message"] == "[==> ] 40/100"
async def test_pull_docker_image_emits_failed(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
raise DockerError("no data received for 60 seconds")
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull):
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
assert response.status_code == status.HTTP_409_CONFLICT
failed = emit_mock.call_args_list[-1].args[1]
assert failed["state"] == "failed"
assert "no data received" in failed["message"]
mock.assert_called_once_with("nginx:latest", force=True)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("image", ["", " ", "nginx latest"])
async def test_pull_docker_image_rejects_invalid_name(
@@ -119,33 +55,6 @@ class TestImagesRoutes:
assert hasattr(stream, "__aiter__")
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_delete_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.delete_image") as mock:
response = await compute_client.delete(
app.url_path_for("compute:delete_docker_image", image="ios-xr/xrd-control-plane:24.4.1")
)
mock.assert_called_once_with("ios-xr/xrd-control-plane:24.4.1", force=False)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_delete_docker_image_rejects_path_traversal(self, app: FastAPI, compute_client: AsyncClient) -> None:
# percent encoded so the client does not normalize the ".." segments
# away before the request reaches the server
with asyncio_patch("gns3server.compute.docker.Docker.delete_image") as mock:
response = await compute_client.delete("/v3/compute/docker/images/a%2F..%2F..%2Fversion")
mock.assert_not_called()
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
async def test_delete_docker_image_force(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.delete_image") as mock:
response = await compute_client.delete(
app.url_path_for("compute:delete_docker_image", image="ubuntu:latest"),
params={"force": "true"},
)
mock.assert_called_once_with("ubuntu:latest", force=True)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_load_docker_image_with_image_and_digest(self, app: FastAPI, compute_client: AsyncClient) -> None:
digest = "sha256:" + "a" * 64
+17 -24
View File
@@ -15,30 +15,23 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
# @pytest.mark.asyncio
# async def test_notification_ws(compute_api):
#
# # FIXME: how to test websockets
# pass
from gns3server.api.routes.compute import compute_api
from gns3server.api.routes.compute.notifications import project_ws_notifications
# with compute_api.ws("/notifications/ws") as ws:
@pytest.mark.asyncio
async def test_compute_ws_stops_when_app_is_exiting() -> None:
# plain assignment: see the note in test_notifications.py — monkeypatch
# cannot round-trip attributes of Starlette's State on pytest 9.2+
compute_api.state.exiting = True
try:
websocket = MagicMock()
websocket.app = compute_api
websocket.client = SimpleNamespace(host="127.0.0.1", port=1000)
websocket.send_text = AsyncMock()
await asyncio.wait_for(project_ws_notifications(websocket=websocket), timeout=1)
websocket.send_text.assert_not_awaited()
finally:
compute_api.state.exiting = False
# answer = await ws.receive_text()
# print(answer)
# answer = json.loads(answer)
#
# assert answer["action"] == "ping"
#
# NotificationManager.instance().emit("test", {})
#
# answer = await ws.receive_text()
# answer = json.loads(answer)
# assert answer["action"] == "test"
+2 -186
View File
@@ -182,10 +182,7 @@ class TestComputeFeatures:
) as mock:
response = await client.get(app.url_path_for("docker_get_images", compute_id=compute_id))
mock.assert_called_with("GET", "docker", "images")
assert response.json() == [
{"image": "docker1", "id": None, "size": None, "created": None},
{"image": "docker2", "id": None, "size": None, "created": None},
]
assert response.json() == [{"image": "docker1"}, {"image": "docker2"}]
async def test_compute_pull_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
@@ -197,188 +194,7 @@ class TestComputeFeatures:
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
json={"image": "nginx:latest"},
)
compute.forward.assert_called_with(
"POST", "docker", "images/pull", data={"image": "nginx:latest"}, params=None
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_pull_docker_image_progress_id(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
compute = MagicMock()
compute.forward = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.post(
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
json={"image": "nginx:latest"},
params={"progress_id": "abc123"},
)
compute.forward.assert_called_with(
"POST",
"docker",
"images/pull",
data={"image": "nginx:latest"},
params={"progress_id": "abc123"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_delete_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for(
"docker_delete_image",
compute_id=test_compute.compute_id,
image="ios-xr/xrd-control-plane:24.4.1",
),
params={"force": True},
)
compute.http_query.assert_called_once_with(
"DELETE",
"/docker/images/ios-xr/xrd-control-plane:24.4.1",
params={"force": "true"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_delete_docker_image_rejects_path_traversal(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
# percent encoded so the client does not normalize the ".." segments
# away before the request reaches the server
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
f"/v3/computes/{test_compute.compute_id}/docker/images/a%2F..%2F..%2Fversion"
)
compute.http_query.assert_not_called()
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
async def test_compute_delete_docker_image_refused_while_referenced_by_templates(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
"""
A deletion is refused while a template references the image, matched
through the image reference spelling, and the compute is not touched.
"""
new_template = {
"name": "Alpine conflict test",
"compute_id": "local",
# a different spelling of the deleted 'alpine:3'
"image": "docker.io/library/alpine:3",
"template_type": "docker",
}
response = await client.post(app.url_path_for("create_template"), json=new_template)
assert response.status_code == status.HTTP_201_CREATED
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for("docker_delete_image", compute_id=test_compute.compute_id, image="alpine:3")
)
assert response.status_code == status.HTTP_409_CONFLICT
body = response.json()
assert body["code"] == "image_referenced_by_templates"
assert body["details"]["referenced_by_templates"] == ["Alpine conflict test"]
assert "referenced by template(s): Alpine conflict test" in body["message"]
compute.http_query.assert_not_called()
async def test_compute_delete_docker_image_force_overrides_template_reference(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
"""
force=true deletes an image referenced by templates and the deletion
notification reports them.
"""
new_template = {
"name": "Alpine force test",
"compute_id": "local",
# an image no other test in this class references: the assertions
# below are exact and the class shares one database
"image": "gns3/force-delete-test:2",
"template_type": "docker",
}
response = await client.post(app.url_path_for("create_template"), json=new_template)
assert response.status_code == status.HTTP_201_CREATED
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for(
"docker_delete_image", compute_id=test_compute.compute_id, image="gns3/force-delete-test:2"
),
params={"force": True},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
compute.http_query.assert_called_once_with(
"DELETE", "/docker/images/gns3/force-delete-test:2", params={"force": "true"}
)
controller.return_value.notification.controller_emit.assert_called_once_with(
"docker.image.deleted",
{
"compute_id": str(test_compute.compute_id),
"image": "gns3/force-delete-test:2",
"referenced_by_templates": ["Alpine force test"],
},
)
async def test_compute_delete_docker_image_notifies_without_templates(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
"""
A deletion nothing references still emits a docker.image.deleted
notification, so clients can refresh their image lists.
"""
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for(
"docker_delete_image", compute_id=test_compute.compute_id, image="gns3/delete-event-test:1"
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
controller.return_value.notification.controller_emit.assert_called_once_with(
"docker.image.deleted",
{
"compute_id": str(test_compute.compute_id),
"image": "gns3/delete-event-test:1",
"referenced_by_templates": [],
},
)
async def test_compute_load_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.post(
app.url_path_for("docker_load_image", compute_id=test_compute.compute_id),
content=b"docker save tar data",
headers={"Content-Type": "application/octet-stream"},
)
compute.http_query.assert_called_once()
args, kwargs = compute.http_query.call_args
assert args[0] == "POST"
assert args[1] == "/docker/images/load"
assert kwargs["timeout"] is None
# the request body must be streamed, not buffered
assert hasattr(kwargs["data"], "__aiter__")
compute.forward.assert_called_with("POST", "docker", "images/pull", data={"image": "nginx:latest"})
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None:
@@ -20,9 +20,7 @@ from fastapi import FastAPI, status
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.schemas.controller.users import User, UserGroupCreate
pytestmark = pytest.mark.asyncio
@@ -86,36 +84,6 @@ class TestGroupRoutes:
response = await client.delete(app.url_path_for("delete_user_group", user_group_id=group_in_db.user_group_id))
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_delete_group_removes_ace_on_group_path(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
"""
A user ACE on the group path is not covered by the group_id foreign key
cascade: deleting the group must remove it by path.
"""
response = await client.post(app.url_path_for("create_user_group"), json={"name": "ace_cleanup_group"})
assert response.status_code == status.HTTP_201_CREATED
group_id = response.json()["user_group_id"]
# any existing user works: the ACE references the user, not the group
user = await UsersRepository(db_session).get_user_by_username("admin")
role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
rbac_repo = RbacRepository(db_session)
await rbac_repo.create_ace(
ACECreate(
path=f"/access/groups/{group_id}",
ace_type="user",
propagate=False,
user_id=str(user.user_id),
role_id=str(role_id),
)
)
response = await client.delete(app.url_path_for("delete_user_group", user_group_id=group_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert await rbac_repo.check_ace_exists(f"/access/groups/{group_id}") is False
class TestGroupMembersRoutes:
async def test_add_to_group_already_member(
@@ -14,22 +14,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import json
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from pydantic import ValidationError
from gns3server import schemas
from gns3server.api.routes.controller.controller import controller_ws_notifications
from gns3server.api.routes.controller.projects import (
project_marker_ws_notifications,
project_ws_notifications,
)
from gns3server.utils.notification_queue import NotificationQueue
STREAM_PATHS = ["/v3/notifications", "/v3/projects/{project_id}/notifications"]
@@ -94,60 +86,3 @@ class TestNotificationSchema:
notification = schemas.Notification.model_validate(message)
assert notification.action == schemas.NotificationAction.log_warning
assert notification.compute_id == "local"
def _fake_websocket(app: FastAPI) -> MagicMock:
websocket = MagicMock()
websocket.app = app
websocket.client = SimpleNamespace(host="127.0.0.1", port=1000)
websocket.send_text = AsyncMock()
return websocket
class TestNotificationWebSocketShutdown:
"""Notification WebSockets must stop when the server is shutting down.
Otherwise uvicorn has to wait for them during graceful shutdown.
"""
# Do not use monkeypatch.setattr on app.state: Starlette's State keeps
# attributes in an internal dict, not in the instance __dict__, so pytest
# 9.2+ cannot read back the old value and its teardown deletes the
# attribute for the rest of the test session.
@pytest.mark.asyncio
async def test_controller_ws_stops_when_app_is_exiting(self, app: FastAPI) -> None:
app.state.exiting = True
try:
websocket = _fake_websocket(app)
await asyncio.wait_for(controller_ws_notifications(websocket, current_user=MagicMock()), timeout=1)
websocket.send_text.assert_not_awaited()
finally:
app.state.exiting = False
@pytest.mark.asyncio
async def test_project_ws_stops_when_app_is_exiting(self, app: FastAPI, project) -> None:
project.auto_close = False
app.state.exiting = True
try:
websocket = _fake_websocket(app)
await asyncio.wait_for(project_ws_notifications(project.id, websocket, current_user=MagicMock()), timeout=1)
websocket.send_text.assert_not_awaited()
finally:
app.state.exiting = False
@pytest.mark.asyncio
async def test_marker_ws_stops_when_app_is_exiting(self, app: FastAPI, project) -> None:
app.state.exiting = True
try:
websocket = _fake_websocket(app)
await asyncio.wait_for(
project_marker_ws_notifications(project.id, websocket, current_user=MagicMock()), timeout=1
)
websocket.send_text.assert_not_awaited()
finally:
app.state.exiting = False
@@ -1,191 +0,0 @@
#
# 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/>.
#
"""
Tests for GET /projects/{project_id}/me/privileges: the per-project view of
the current user's effective privileges (unlike /access/users/me/privileges,
evaluated on the project resource path).
"""
import uuid
import pytest
import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.controller import Controller
from gns3server.controller.project import Project
from gns3server.db.models import User
from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.schemas.controller.pools import ResourceCreate, ResourcePoolCreate
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
pytestmark = pytest.mark.asyncio
class TestProjectPrivilegesRoute:
"""
Every test creates its own project (unique id) and its own ACEs, so the
file is order-independent under --random-order.
"""
@pytest_asyncio.fixture
async def project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> Project:
project_id = str(uuid.uuid4())
params = {"name": f"privileges-{project_id[:8]}", "project_id": project_id}
response = await client.post(app.url_path_for("create_project"), json=params)
assert response.status_code == status.HTTP_201_CREATED
return controller.get_project(project_id)
@staticmethod
def _user_headers(user: User) -> dict:
token = auth_service.create_access_token(user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
return {"Authorization": f"Bearer {token}"}
async def _create_user_ace(self, db_session: AsyncSession, path: str, user: User, role_name: str) -> None:
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name(role_name)
await rbac_repo.create_ace(
ACECreate(
path=path,
ace_type="user",
propagate=True,
user_id=str(user.user_id),
role_id=str(role.role_id),
)
)
async def test_superadmin_gets_all_privileges(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
response = await client.get(app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is True
assert "Project.Audit" in data["privileges"]
assert "Project.Modify" in data["privileges"]
assert "Node.PowerMgmt" in data["privileges"]
async def test_unknown_project_returns_404(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=str(uuid.uuid4()))
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_user_without_access_gets_403(
self, app: FastAPI, client: AsyncClient, project: Project, test_user: User
) -> None:
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_user_ace_grants_project_scoped_privileges(
self,
app: FastAPI,
client: AsyncClient,
project: Project,
test_user: User,
db_session: AsyncSession,
) -> None:
# the "User" role carries the whole project-subtree family
await self._create_user_ace(db_session, f"/projects/{project.id}", test_user, "User")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is False
for privilege in (
"Project.Audit",
"Project.Modify",
"Node.Modify",
"Node.Console",
"Node.PowerMgmt",
"Link.Capture",
"Snapshot.Restore",
):
assert privilege in data["privileges"]
# privileges outside the role must not appear, even path-scoped
assert "User.Audit" not in data["privileges"]
assert "Template.Modify" not in data["privileges"]
async def test_auditor_role_gets_audit_only(
self,
app: FastAPI,
client: AsyncClient,
project: Project,
test_user: User,
db_session: AsyncSession,
) -> None:
await self._create_user_ace(db_session, f"/projects/{project.id}", test_user, "Auditor")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_200_OK
privileges = response.json()["privileges"]
assert "Project.Audit" in privileges
assert "Node.Audit" in privileges
assert "Link.Audit" in privileges
# auditors can look but not touch
assert "Project.Modify" not in privileges
assert "Node.Modify" not in privileges
assert "Node.Console" not in privileges
assert "Snapshot.Restore" not in privileges
async def test_pool_membership_grants_privileges(
self,
app: FastAPI,
client: AsyncClient,
controller: Controller,
project: Project,
test_user: User,
db_session: AsyncSession,
) -> None:
pools_repo = ResourcePoolsRepository(db_session)
pool = await pools_repo.create_resource_pool(ResourcePoolCreate(name=f"pool-{uuid.uuid4().hex[:8]}"))
resource = await pools_repo.create_resource(
ResourceCreate(resource_id=uuid.UUID(project.id), resource_type="project", name=project.name)
)
await pools_repo.add_resource_to_pool(pool.resource_pool_id, resource)
await self._create_user_ace(db_session, f"/pools/{pool.resource_pool_id}", test_user, "Auditor")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_200_OK
privileges = response.json()["privileges"]
assert "Project.Audit" in privileges
assert "Project.Modify" not in privileges
@@ -526,8 +526,6 @@ class TestControllerProjectRoutes:
with open(os.path.join(project.path, "demo")) as f:
content = f.read()
assert content == "hello"
# the imported project is owned by the user who imported it
assert project.created_by == "admin"
async def test_import_with_project_name(
self, app: FastAPI, client: AsyncClient, tmpdir, controller: Controller
@@ -558,8 +556,6 @@ class TestControllerProjectRoutes:
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "hello"
# the duplicated project is owned by the user who duplicated it
assert response.json()["created_by"] == "admin"
async def test_lock_unlock(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
+1 -174
View File
@@ -27,12 +27,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
import gns3server.db.models as models
from gns3server.config import Config
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.schemas.controller.users import User, UserCreate, UserGroupCreate
from gns3server.schemas.controller.users import User
from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
pytestmark = pytest.mark.asyncio
@@ -150,36 +147,6 @@ class TestUserRoutes:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 4 # admin, user1, user2 and user3 should exist
async def test_delete_user_removes_ace_on_user_path(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
"""
A group ACE on the user path is not covered by the user_id foreign key
cascade: deleting the user must remove it by path.
"""
new_user = {"username": "ace_cleanup_user", "email": "ace_cleanup_user@email.com", "password": "test_password"}
response = await client.post(app.url_path_for("create_user"), json=new_user)
assert response.status_code == status.HTTP_201_CREATED
user_id = response.json()["user_id"]
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
rbac_repo = RbacRepository(db_session)
await rbac_repo.create_ace(
ACECreate(
path=f"/access/users/{user_id}",
ace_type="group",
propagate=False,
group_id=str(group_id),
role_id=str(role_id),
)
)
response = await client.delete(app.url_path_for("delete_user", user_id=user_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert await rbac_repo.check_ace_exists(f"/access/users/{user_id}") is False
class TestAuthTokens:
async def test_can_create_token_successfully(
@@ -744,143 +711,3 @@ class TestSuperAdmin:
}
response = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
assert response.status_code == status.HTTP_200_OK
class TestUserPrivileges:
"""
Tests for GET /access/users/me/privileges.
Every test creates its own users, groups and ACEs: no shared rows with
other tests in this file, and authentication is sent per-request so the
class stays order-independent.
"""
async def _create_user(self, db_session: AsyncSession, username: str) -> models.User:
return await UsersRepository(db_session).create_user(
UserCreate(username=username, email=f"{username}@email.com", password="secret_password")
)
async def _create_group_with_member(
self, db_session: AsyncSession, name: str, user: models.User
) -> models.UserGroup:
users_repo = UsersRepository(db_session)
group = await users_repo.create_user_group(UserGroupCreate(name=name))
await users_repo.add_member_to_user_group(group.user_group_id, user)
return group
@staticmethod
def _headers(username: str) -> dict:
token = auth_service.create_access_token(username, secret_key=DEFAULT_JWT_SECRET_KEY)
return {"Authorization": f"Bearer {token}"}
async def test_superadmin_gets_all_privileges(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
rbac_repo = RbacRepository(db_session)
expected = sorted(privilege.name for privilege in await rbac_repo.get_privileges())
response = await client.get(app.url_path_for("get_logged_in_user_privileges"), headers=self._headers("admin"))
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is True
assert data["privileges"] == expected
async def test_user_without_ace_has_no_privileges(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
user = await self._create_user(db_session, "priv_no_ace")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is False
assert data["privileges"] == []
async def test_privileges_from_user_ace(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
user = await self._create_user(db_session, "priv_user_ace")
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name("Administrator")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="user", user_id=user.user_id, role_id=role.role_id)
)
expected = sorted(privilege.name for privilege in await rbac_repo.get_role_privileges(role.role_id))
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["privileges"] == expected
async def test_privileges_from_group_ace(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
user = await self._create_user(db_session, "priv_group_ace")
group = await self._create_group_with_member(db_session, "priv_group", user)
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name("Auditor")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="group", group_id=group.user_group_id, role_id=role.role_id)
)
expected = sorted(privilege.name for privilege in await rbac_repo.get_role_privileges(role.role_id))
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["privileges"] == expected
async def test_privileges_are_the_union_of_user_and_group_aces(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
user = await self._create_user(db_session, "priv_union")
group = await self._create_group_with_member(db_session, "priv_union_group", user)
rbac_repo = RbacRepository(db_session)
user_role = await rbac_repo.get_role_by_name("Auditor")
group_role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="user", user_id=user.user_id, role_id=user_role.role_id)
)
await rbac_repo.create_ace(
ACECreate(path="/templates", ace_type="group", group_id=group.user_group_id, role_id=group_role.role_id)
)
expected = set(privilege.name for privilege in await rbac_repo.get_role_privileges(user_role.role_id))
expected.update(privilege.name for privilege in await rbac_repo.get_role_privileges(group_role.role_id))
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is False
assert data["privileges"] == sorted(expected)
async def test_denial_ace_is_not_reported_as_a_privilege(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
user = await self._create_user(db_session, "priv_denied")
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name("Administrator")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="user", user_id=user.user_id, role_id=role.role_id, allowed=False)
)
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["privileges"] == []
async def test_privileges_require_authentication(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers={"Authorization": "Bearer invalid_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
+9 -282
View File
@@ -16,20 +16,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import uuid
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.docker import (
DOCKER_MINIMUM_API_VERSION,
DOCKER_PREFERRED_API_VERSION,
Docker,
)
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error, DockerHttp409Error
from gns3server.compute.docker.docker_images import PULL_IDLE_TIMEOUT
from gns3server.compute.project_manager import ProjectManager
from gns3server.compute.docker import DOCKER_MINIMUM_API_VERSION, DOCKER_PREFERRED_API_VERSION, Docker
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from tests.utils import AsyncioMagicMock, asyncio_patch
@@ -139,64 +132,11 @@ async def test_list_images():
images = await Docker.instance().list_images()
mock.assert_called_with("GET", "images/json", params={"all": 0})
assert len(images) == 5
by_tag = {image["image"]: image for image in images}
assert by_tag["ubuntu:12.04"]["id"] == "8dbd9e392a964056420e5d58ca5cc376ef18e2de93b5cc90e868a1bbc8318c1c"
assert by_tag["ubuntu:12.04"]["size"] == 131506275
assert by_tag["ubuntu:12.04"]["created"] == 1365714795
# tags of the same image share its id
assert by_tag["ubuntu:precise"]["id"] == by_tag["ubuntu:12.04"]["id"]
assert "<none>:<none>" not in by_tag
assert {"ubuntu:precise", "ubuntu:latest", "ubuntu:12.10", "ubuntu:quantal"} <= set(by_tag)
@pytest.mark.asyncio
async def test_list_images_created_fallback_to_oci_label():
"""
Images built without a created timestamp (e.g. reproducible Ubuntu images)
report 0 from the daemon: the build time comes from the OCI created label.
"""
response = [
{
# zero timestamp, the label is used (Z suffix, nanosecond precision)
"RepoTags": ["ubuntu:latest"],
"Id": "febc1a2eec1f",
"Created": 0,
"Size": 78101592,
"Labels": {"org.opencontainers.image.created": "2026-10-25T10:26:07.538865123Z"},
},
{
# zero timestamp without a label stays at zero
"RepoTags": ["no-label:latest"],
"Id": "aaaaaa",
"Created": 0,
"Size": 1,
},
{
# the daemon timestamp wins over the label when it is set
"RepoTags": ["normal:latest"],
"Id": "bbbbbb",
"Created": 1365714795,
"Size": 1,
"Labels": {"org.opencontainers.image.created": "2000-01-01T00:00:00Z"},
},
{
# a broken label is ignored, the timestamp stays at zero
"RepoTags": ["broken-label:latest"],
"Id": "cccccc",
"Created": 0,
"Size": 1,
"Labels": {"org.opencontainers.image.created": "not a date"},
},
]
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response):
images = await Docker.instance().list_images()
by_tag = {image["image"]: image for image in images}
assert by_tag["ubuntu:latest"]["created"] == 1792923967
assert by_tag["no-label:latest"]["created"] == 0
assert by_tag["normal:latest"]["created"] == 1365714795
assert by_tag["broken-label:latest"]["created"] == 0
assert {"image": "ubuntu:12.04"} in images
assert {"image": "ubuntu:precise"} in images
assert {"image": "ubuntu:latest"} in images
assert {"image": "ubuntu:12.10"} in images
assert {"image": "ubuntu:quantal"} in images
@pytest.mark.asyncio
@@ -225,50 +165,7 @@ async def test_pull_image():
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=mock_query) as mock:
await Docker.instance().pull_image("ubuntu")
args, kwargs = mock.call_args
assert args == ("POST", "images/create")
assert kwargs["params"] == {"fromImage": "ubuntu", "tag": "latest"}
# no total timeout (a pull can take as long as it needs) but an
# idle socket read timeout, so an unreachable registry fails
assert kwargs["timeout"].total is None
assert kwargs["timeout"].sock_read == PULL_IDLE_TIMEOUT
@pytest.mark.asyncio
async def test_pull_image_reports_aggregated_progress():
class Content:
def __init__(self):
self._chunks = [
b'{"status": "Pulling fs layer", "id": "sha256:a"}\n'
b'{"status": "Pulling fs layer", "id": "sha256:b"}\n'
b'{"status": "Downloading", "id": "sha256:a", "progress": "[==> ] 40/100"}\n'
b'{"status": "Pull complete", "id": "sha256:a"}\n',
b'{"status": "Pull complete", "id": "sha256:b"}\n',
b"",
]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
progress = []
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
await Docker.instance().pull_image("ubuntu", progress_callback=progress.append)
# the callback sees the running totals of layers, not raw daemon lines
assert progress[0] == {
"layers_total": 0,
"layers_done": 0,
"message": "Pulling 'ubuntu' from Docker repository",
"state": "started",
}
assert progress[1] == {"layers_total": 1, "layers_done": 0, "message": "Pulling fs layer"}
assert progress[2] == {"layers_total": 2, "layers_done": 0, "message": "Pulling fs layer"}
assert {"layers_total": 2, "layers_done": 0, "message": "[==> ] 40/100"} in progress
assert progress[-1] == {"layers_total": 2, "layers_done": 2, "message": "Pull complete"}
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
@@ -291,40 +188,7 @@ async def test_force_pull_image():
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image("ubuntu", force=True)
query_mock.assert_not_called()
args, kwargs = pull_mock.call_args
assert args == ("POST", "images/create")
assert kwargs["params"] == {"fromImage": "ubuntu", "tag": "latest"}
assert kwargs["timeout"].sock_read == PULL_IDLE_TIMEOUT
@pytest.mark.asyncio
@pytest.mark.parametrize(
"image, expected_params",
[
# without a tag or digest the daemon would pull every tag of the
# repository: default to latest so the pull targets a single tag
("ubuntu", {"fromImage": "ubuntu", "tag": "latest"}),
("gns3/ubuntu", {"fromImage": "gns3/ubuntu", "tag": "latest"}),
# the ':' before the last '/' is a registry port, not a tag
("registry:5000/ubuntu", {"fromImage": "registry:5000/ubuntu", "tag": "latest"}),
("registry:5000/gns3/ubuntu", {"fromImage": "registry:5000/gns3/ubuntu", "tag": "latest"}),
# an explicit tag or digest is left alone
("ubuntu:24.04", {"fromImage": "ubuntu:24.04"}),
("registry:5000/ubuntu:24.04", {"fromImage": "registry:5000/ubuntu:24.04"}),
("ubuntu@sha256:8b2f", {"fromImage": "ubuntu@sha256:8b2f"}),
],
)
async def test_pull_image_targets_a_single_tag(image, expected_params):
response = MagicMock()
response.content.read = AsyncioMagicMock(return_value=b"")
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image(image)
args, kwargs = pull_mock.call_args
assert args == ("POST", "images/create")
assert kwargs["params"] == expected_params
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
@@ -347,24 +211,6 @@ async def test_pull_image_error():
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_not_found_is_not_reported_as_a_network_error():
"""
A 404 from the registry means the image name is wrong or the registry needs
authentication: telling the user to check the Internet connection sends
them in the wrong direction.
"""
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch(
"gns3server.compute.docker.Docker.http_query",
side_effect=DockerHttp404Error("Docker has returned an error: 404 pull access denied for debian13"),
):
with pytest.raises(DockerError, match="not found in the registry") as error:
await Docker.instance().pull_image("debian13")
assert "Internet connection" not in str(error.value)
@pytest.mark.asyncio
async def test_pull_image_rejects_incomplete_response():
@@ -484,125 +330,6 @@ async def test_load_image_error():
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_delete_image():
docker = Docker.instance()
docker._image_id_translations["ubuntu:latest"] = ("sha256:" + "a" * 64, "sha256:" + "b" * 64)
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock:
await docker.delete_image("ubuntu:latest", force=True)
# the daemon query string only accepts strings, ints and floats: a
# Python bool would make the aiohttp URL builder raise a TypeError
mock.assert_called_with("DELETE", "images/ubuntu:latest", params={"force": "true"})
# deleting the image drops the id translation recorded for a previous sync
assert docker.image_id_translation("ubuntu:latest") is None
@pytest.mark.asyncio
async def test_delete_image_error_keeps_translation():
"""
When the daemon refuses the deletion the image is still there, so the id
translation recorded for a previous sync stays valid.
"""
docker = Docker.instance()
translation = ("sha256:" + "a" * 64, "sha256:" + "b" * 64)
docker._image_id_translations["ubuntu:latest"] = translation
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerError("conflict: image is in use")):
with pytest.raises(DockerError, match="image is in use"):
await docker.delete_image("ubuntu:latest")
assert docker.image_id_translation("ubuntu:latest") == translation
DAEMON_DELETE_CONFLICT = (
"Docker has returned an error: 409 conflict: unable to remove repository reference "
'"alpine:3" (must force) - container 6d59f00236ff is using its referenced image d529dd0c6e55'
)
@pytest.mark.asyncio
async def test_delete_image_conflict_names_the_node_and_project():
"""
A container blocking the deletion is reported as the GNS3 node and project
it belongs to, not as a raw container id.
"""
docker = Docker.instance()
project_id = str(uuid.uuid4())
project = ProjectManager.instance().create_project(name="test-lab", project_id=project_id)
async def query(method, path, **kwargs):
assert path.startswith("images/") or path.startswith("containers/")
if method == "DELETE":
raise DockerHttp409Error(DAEMON_DELETE_CONFLICT)
return {"Name": f"/GNS3.alpine-1.{project_id}", "State": {"Running": True}}
try:
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=query):
with pytest.raises(DockerHttp409Error) as excinfo:
await docker.delete_image("alpine:3")
message = str(excinfo.value)
assert "node 'alpine-1' in project 'test-lab' (running)" in message
assert "force=true" in message
assert "6d59f00236ff" not in message
finally:
ProjectManager.instance().remove_project(project.id)
@pytest.mark.asyncio
async def test_delete_image_conflict_reports_a_leftover_container():
"""
When the project of the blocking container is closed on this compute the
container is reported as a leftover.
"""
docker = Docker.instance()
async def query(method, path, **kwargs):
if method == "DELETE":
raise DockerHttp409Error(DAEMON_DELETE_CONFLICT)
return {"Name": f"/GNS3.E2E-2.{uuid.uuid4()}", "State": {"Running": False}}
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=query):
with pytest.raises(DockerHttp409Error, match="leftover container of node 'E2E-2' \\(stopped"):
await docker.delete_image("alpine:3")
@pytest.mark.asyncio
async def test_delete_image_conflict_reports_a_non_gns3_container():
"""
A container created outside GNS3 is reported by its plain container name.
"""
docker = Docker.instance()
async def query(method, path, **kwargs):
if method == "DELETE":
raise DockerHttp409Error(DAEMON_DELETE_CONFLICT)
return {"Name": "/my-own-container", "State": {"Running": True}}
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=query):
with pytest.raises(DockerHttp409Error, match="container 'my-own-container' \\(running\\)"):
await docker.delete_image("alpine:3")
@pytest.mark.asyncio
async def test_delete_image_conflict_without_containers_is_not_rewritten():
"""
A daemon conflict naming no container (e.g. a multi repository reference)
is passed through unchanged.
"""
docker = Docker.instance()
original = "Docker has returned an error: 409 conflict: image is referenced in multiple repositories"
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp409Error(original)):
with pytest.raises(DockerHttp409Error, match="multiple repositories"):
await docker.delete_image("alpine:3")
@pytest.mark.asyncio
async def test_docker_check_connection_docker_minimum_version(vm):
+160 -9
View File
@@ -729,12 +729,10 @@ async def test_build_command(vm, fake_qemu_binary):
"256M",
"-smp",
"cpus=1,maxcpus=1,sockets=1",
"-machine",
"q35",
"-boot",
"order=c",
"-uuid",
vm.id,
"-serial",
f"telnet:127.0.0.1:{vm._internal_console_port},server,nowait",
"-net",
"none",
"-device",
@@ -745,6 +743,10 @@ async def test_build_command(vm, fake_qemu_binary):
f"e1000,mac={vm._mac_address},bus=pci-bridge1,addr=0x00,netdev=gns3-0",
"-netdev",
f"socket,id=gns3-0,udp=127.0.0.1:{nio.rport},localaddr=127.0.0.1:{nio.lport}",
"-uuid",
vm.id,
"-serial",
f"telnet:127.0.0.1:{vm._internal_console_port},server,nowait",
"-display",
"none",
]
@@ -823,12 +825,10 @@ async def test_build_command_two_adapters(vm, fake_qemu_binary):
"256M",
"-smp",
"cpus=1,maxcpus=1,sockets=1",
"-machine",
"q35",
"-boot",
"order=c",
"-uuid",
vm.id,
"-serial",
f"telnet:127.0.0.1:{vm._internal_console_port},server,nowait",
"-net",
"none",
"-device",
@@ -843,6 +843,10 @@ async def test_build_command_two_adapters(vm, fake_qemu_binary):
f"e1000,mac={int_to_macaddress(macaddress_to_int(vm._mac_address) + 1)},bus=pci-bridge1,addr=0x01,netdev=gns3-1",
"-netdev",
f"socket,id=gns3-1,udp=127.0.0.1:{nio2.rport},localaddr=127.0.0.1:{nio2.lport}",
"-uuid",
vm.id,
"-serial",
f"telnet:127.0.0.1:{vm._internal_console_port},server,nowait",
"-display",
"none",
]
@@ -919,7 +923,154 @@ async def test_build_command_with_virtio_net_pci_adapter(vm):
vm._adapter_type = "virtio-net-pci"
with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()):
cmd = await vm._build_command()
assert "virtio-net-pci,mac=00:00:ab:0e:0f:09,speed=10000,duplex=full,bus=pci-bridge1,addr=0x00,netdev=gns3-0" in cmd
assert "virtio-net-pci,mac=00:00:ab:0e:0f:09,speed=10000,duplex=full,bus=gns3-pcie-0,addr=0x00,netdev=gns3-0" in cmd
@pytest.mark.asyncio
@pytest.mark.parametrize("guest_platform", ["aarch64", "i386", "x86_64"])
@pytest.mark.parametrize("adapter_type", ["e1000e", "igb", "virtio-net-pci", "vmxnet3"])
async def test_network_options_pcie_root_ports(vm, guest_platform, adapter_type):
vm._platform = guest_platform
vm.adapter_type = adapter_type
vm.adapters = 9
options = await vm._network_options()
assert not any("pci-bridge" in option for option in options)
for n in range(9):
assert (
f"pcie-root-port,id=gns3-pcie-{n},bus=pcie.0,addr=0x{2 + n // 8:x}.{n % 8},"
f"multifunction=on,chassis={n + 1},hotplug=off"
) in options
assert any(f",bus=gns3-pcie-{n},addr=0x00,netdev=gns3-{n}" in option for option in options)
@pytest.mark.asyncio
@pytest.mark.parametrize("count", [0, 100, 232])
async def test_network_options_pcie_adapter_count(vm, count):
vm.adapter_type = "virtio-net-pci"
vm.adapters = count
options = await vm._network_options()
assert sum(option.startswith("pcie-root-port,") for option in options) == count
assert sum(option.startswith("virtio-net-pci,") for option in options) == count
if count:
assert f",bus=gns3-pcie-{count - 1},addr=0x00,netdev=gns3-{count - 1}" in options[-3]
@pytest.mark.asyncio
async def test_network_options_too_many_pcie_adapters(vm):
vm.adapter_type = "virtio-net-pci"
vm.adapters = 233
with pytest.raises(QemuError, match="At most 232 PCIe network adapters"):
await vm._network_options()
assert vm._local_udp_tunnels == {}
@pytest.mark.asyncio
@pytest.mark.parametrize("adapter_type", ["e1000e", "igb"])
async def test_network_options_pcie_io_space_limit(vm, adapter_type):
vm.adapter_type = adapter_type
vm.adapters = 10
with pytest.raises(QemuError, match="exceeds PCI I/O space"):
await vm._network_options()
@pytest.mark.asyncio
async def test_network_options_mixed_io_space_limit(vm):
vm.adapters = 41
vm.custom_adapters = [{"adapter_number": n, "adapter_type": "e1000e"} for n in range(8)]
with pytest.raises(QemuError, match="exceeds PCI I/O space"):
await vm._network_options()
@pytest.mark.asyncio
async def test_network_options_mixed_pci_and_pcie(vm):
vm.adapters = 3
vm.custom_adapters = [{"adapter_number": 1, "adapter_type": "virtio-net-pci", "mac_address": "00:00:ab:0e:0f:09"}]
options = await vm._network_options()
assert sum(option.startswith("pcie-root-port,") for option in options) == 1
assert "pcie-root-port,id=gns3-pcie-1,bus=pcie.0,addr=0x2.0,multifunction=on,chassis=1,hotplug=off" in options
assert (
"virtio-net-pci,mac=00:00:ab:0e:0f:09,speed=10000,duplex=full,bus=gns3-pcie-1,addr=0x00,netdev=gns3-1"
in options
)
assert any(",bus=pci-bridge1,addr=0x00,netdev=gns3-0" in option for option in options)
assert any(",bus=pci-bridge1,addr=0x01,netdev=gns3-2" in option for option in options)
@pytest.mark.asyncio
async def test_network_options_other_platform_preserves_pci(vm):
vm._platform = "ppc"
vm.adapter_type = "virtio-net-pci"
options = await vm._network_options()
assert not any(option.startswith("pcie-root-port,") for option in options)
assert any(",bus=pci-bridge1,addr=0x00,netdev=gns3-0" in option for option in options)
@pytest.mark.asyncio
@pytest.mark.parametrize("count", [32, 33, 275])
async def test_network_options_legacy_bridge_capacity(vm, count):
vm.adapters = count
options = await vm._network_options()
assert sum(option.startswith("pci-bridge,") for option in options) == (count + 31) // 32
assert sum(option.startswith("e1000,") for option in options) == count
assert not any(option.startswith("pcie-root-port,") for option in options)
@pytest.mark.parametrize("guest_platform", ["i386", "x86_64"])
@pytest.mark.parametrize("options", [[], ["-machine", "q35"], ["-M", "pc-q35-6.2"], ["-machine", "type=q35,accel=tcg"]])
def test_machine_options_q35(vm, guest_platform, options):
vm._platform = guest_platform
assert vm._machine_options(options) == ["-machine", "q35"]
@pytest.mark.parametrize(
("guest_platform", "machine"),
[("x86_64", "pc"), ("i386", "type=pc-i440fx-6.2"), ("aarch64", "virtually-not-virt")],
)
def test_machine_options_incompatible_machine(vm, guest_platform, machine):
vm._platform = guest_platform
with pytest.raises(QemuError, match="incompatible with the PCIe network layout"):
vm._machine_options(["-machine", machine])
def test_machine_options_arm64(vm):
vm._platform = "aarch64"
with patch("gns3server.compute.qemu.qemu_vm.platform.machine", return_value="x86_64"):
assert vm._machine_options(["-machine", "type=virt-6.2", "-cpu", "cortex-a72"]) == [
"-machine",
"virt,gic-version=3",
]
def test_machine_options_other_platform(vm):
vm._platform = "ppc"
assert vm._machine_options(["-machine", "mac99"]) == []
@pytest.mark.asyncio
async def test_build_command_pcie_ports_precede_other_devices(vm):
vm.adapter_type = "virtio-net-pci"
disk_device = "virtio-blk-pci,id=test-disk"
with (
asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()),
patch.object(vm, "_disk_options", new=AsyncioMagicMock(return_value=["-device", disk_device])),
):
cmd = await vm._build_command()
root_port = next(option for option in cmd if option.startswith("pcie-root-port,"))
assert cmd.index(root_port) < cmd.index(disk_device)
assert cmd.index(root_port) < cmd.index("-serial")
@pytest.mark.asyncio
-5
View File
@@ -27,7 +27,6 @@ from gns3server.compute.project_manager import ProjectManager
from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.db.models import Base, Compute, User
from gns3server.db.rbac_state import RbacState
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.computes import Protocol
@@ -83,10 +82,6 @@ async def db_session(db_engine):
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
# the RBAC snapshot is process-wide: drop it so it reloads from the
# freshly recreated tables instead of holding data from a previous class
RbacState.instance().reset()
session = AsyncSession(db_engine, expire_on_commit=False)
try:
yield session
+1 -4
View File
@@ -640,10 +640,7 @@ async def test_connect_notification_poison_frame_autoreconnects(compute, monkeyp
# would execute module-level from-imports while the autouse fixture's
# monkeypatches are active, freezing patched objects into namespaces)
monkeypatch.delattr(sys, "_called_from_test", raising=False)
# plain assignment instead of monkeypatch.setattr: Starlette's State keeps
# attributes in an internal dict, so pytest 9.2+ teardown would delete the
# attribute for the rest of the test session
gns3_app.state.exiting = False
monkeypatch.setattr(gns3_app.state, "exiting", False)
async def fake_connect():
compute._reconnect_attempted = True
-28
View File
@@ -119,34 +119,6 @@ async def test_dispatch_node_updated(controller, node, project):
assert event["properties"]["startup_config"] == "ip 192"
@pytest.mark.asyncio
async def test_dispatch_docker_image_routed_to_controller_stream(controller, project):
"""
docker.image.* events are scoped to a compute, not to a project: they must
reach the controller stream even when no project is open, and not the
project stream.
"""
notif = controller.notification
with notif.controller_queue() as controller_q, notif.project_queue(project.id) as project_q:
await controller_q.get(0.1) # ping
await project_q.get(0.1) # ping
await notif.dispatch(
"docker.image.pull", {"image": "debian", "state": "started"}, project_id=None, compute_id="local"
)
msg = await controller_q.get(5)
assert msg == (
"docker.image.pull",
{"image": "debian", "state": "started", "compute_id": "local"},
{},
)
# the project stream only carries its periodic ping
msg = await project_q.get(0.2)
assert msg[0] == "ping"
@pytest.mark.asyncio
async def test_dispatch_marker_routed_to_marker_channel(controller, project):
"""
+1 -21
View File
@@ -936,10 +936,9 @@ async def test_duplicate(project, controller):
compute, "test", None, node_type="vmware", properties={"startup_config": "test.cfg"}
)
new_project = await project.duplicate(name="Hello", user="user1")
new_project = await project.duplicate(name="Hello")
assert new_project.id != project.id
assert new_project.name == "Hello"
assert new_project.created_by == "user1"
await new_project.open()
@@ -947,25 +946,6 @@ async def test_duplicate(project, controller):
assert list(new_project.nodes.values())[1].compute.id == "remote"
@pytest.mark.asyncio
async def test_duplicate_created_by(project, controller):
"""
The duplicated project is owned by the user who duplicated it (fast path)
"""
project.created_by = "alice"
project.dump()
new_project = await project.duplicate(name="Hello", user="bob")
assert new_project.created_by == "bob"
# the original project keeps its owner
assert project.created_by == "alice"
# without a user the original owner is kept (backward compatible)
new_project = await project.duplicate(name="Hello2")
assert new_project.created_by == "alice"
def test_snapshots(project):
"""
List the snapshots
+10 -102
View File
@@ -29,7 +29,7 @@ from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.pools import ResourceCreate, ResourcePoolCreate
from gns3server.schemas.controller.rbac import ACECreate, ACEUpdate
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.services import auth_service
pytestmark = pytest.mark.asyncio
@@ -80,57 +80,26 @@ class TestPrivileges:
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized == result
async def _get_or_create_projects_ace(
self, db_session: AsyncSession, *, propagate: bool, allowed: bool = True
) -> None:
"""
The tests must not depend on the /projects ACE created by another test:
reuse it when present, otherwise create it with the requested flags.
"""
rbac_repo = RbacRepository(db_session)
ace = await rbac_repo.get_ace_by_path("/projects")
if ace is None:
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await rbac_repo.get_role_by_name("User")).role_id
await rbac_repo.create_ace(
ACECreate(
path="/projects",
ace_type="group",
propagate=propagate,
allowed=allowed,
group_id=str(group_id),
role_id=str(role_id),
)
)
else:
await rbac_repo.update_ace(
ace.ace_id,
ACEUpdate(
ace_type=ace.ace_type,
path=ace.path,
role_id=ace.role_id,
propagate=propagate,
allowed=allowed,
),
)
async def test_propagate(self, test_user: User, db_session: AsyncSession):
privilege = "Project.Audit"
path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
rbac_repo = RbacRepository(db_session)
authorized = await rbac_repo.check_user_has_privilege(test_user.user_id, path, privilege)
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized is False
await self._get_or_create_projects_ace(db_session, propagate=True)
ace = await RbacRepository(db_session).get_ace_by_path("/projects")
ace.propagate = True
await db_session.commit()
authorized = await rbac_repo.check_user_has_privilege(test_user.user_id, path, privilege)
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized is True
async def test_allowed(self, test_user: User, db_session: AsyncSession):
await self._get_or_create_projects_ace(db_session, propagate=True, allowed=False)
ace = await RbacRepository(db_session).get_ace_by_path("/projects")
ace.allowed = False
ace.propagate = True
await db_session.commit()
privilege = "Project.Audit"
path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
@@ -146,27 +115,6 @@ class TestPrivileges:
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized is True
async def test_delete_all_ace_starting_with_path_is_committed(self, db_session: AsyncSession):
"""
Production requests get a fresh session that is only closed at
teardown, rolling back uncommitted work: the bulk ACE deletion must
be committed to survive the end of the request.
"""
rbac_repo = RbacRepository(db_session)
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await rbac_repo.get_role_by_name("User")).role_id
path = f"/projects/{uuid.uuid4()}"
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role_id))
)
await rbac_repo.delete_all_ace_starting_with_path(path)
# simulate the production session teardown: close() rolls back the
# open transaction and an uncommitted deletion would be lost
await db_session.rollback()
assert await rbac_repo.check_ace_exists(path) is False
class TestResourcePools:
async def test_resource_pool(self, test_user: User, db_session: AsyncSession):
@@ -273,46 +221,6 @@ class TestResourcePools:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 3
async def test_list_projects_legacy_without_created_by(
self, app: FastAPI, controller: Controller, base_client: AsyncClient, db_session: AsyncSession, test_user: User
) -> None:
"""
Projects created before GNS3 3.1 have no created_by field. They should
remain visible through a direct ACE (the pre-3.1 visibility rule)
instead of being hidden from every non-superadmin user.
"""
# Clean up any existing ACEs from previous tests
await RbacRepository(db_session).delete_all_ace_starting_with_path("/projects")
await RbacRepository(db_session).delete_all_ace_starting_with_path("/pools")
legacy_id = str(uuid.uuid4()) # created with GNS3 < 3.1, no created_by
owned_id = str(uuid.uuid4())
other_id = str(uuid.uuid4())
await controller.add_project(project_id=legacy_id, name="LegacyProject")
await controller.add_project(project_id=owned_id, name="OwnedProject", created_by=test_user.username)
await controller.add_project(project_id=other_id, name="OtherProject", created_by="admin")
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
ace = ACECreate(
path="/projects", ace_type="group", propagate=True, group_id=str(group_id), role_id=str(role_id)
)
await RbacRepository(db_session).create_ace(ace)
access_token = auth_service.create_access_token(test_user.username)
async with AsyncClient(
base_url="http://test-api",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"},
transport=ASGIWebSocketTransport(app=app),
) as user_client:
response = await user_client.get(app.url_path_for("get_projects"))
assert response.status_code == status.HTTP_200_OK
projects = response.json()
# the legacy project (no owner) and the user's own project are visible,
# but the project owned by another user is not
assert {p["name"] for p in projects} == {"LegacyProject", "OwnedProject"}
# class TestProjectsWithRbac:
#
-209
View File
@@ -1,209 +0,0 @@
#
# 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/>.
#
"""
Tests for the in-memory RBAC snapshot: every write through a repository
must be visible to the next permission check (write-through), and the
snapshot must survive a reset (database recreated).
"""
import uuid
import pytest
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.models import User
from gns3server.db.rbac_state import RbacState
from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.pools import ResourceCreate, ResourcePoolCreate
from gns3server.schemas.controller.rbac import ACECreate, RoleCreate
from gns3server.schemas.controller.users import UserGroupCreate
pytestmark = pytest.mark.asyncio
class TestRbacStateWriteThrough:
"""
Each test builds its own rows (unique paths and names) so the file is
order-independent under --random-order.
"""
async def test_group_membership_change_is_visible(self, test_user: User, db_session: AsyncSession) -> None:
path = f"/projects/{uuid.uuid4()}"
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
group = await users_repo.create_user_group(UserGroupCreate(name=f"group-{uuid.uuid4().hex[:8]}"))
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(
path=path,
ace_type="group",
propagate=False,
group_id=str(group.user_group_id),
role_id=str(role.role_id),
)
)
# the user is not a member of the group yet
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
await users_repo.add_member_to_user_group(group.user_group_id, test_user)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
await users_repo.remove_member_from_user_group(group.user_group_id, test_user)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
async def test_role_privilege_change_is_visible(self, test_user: User, db_session: AsyncSession) -> None:
path = f"/projects/{uuid.uuid4()}"
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
# a role without any privilege
role = await rbac_repo.create_role(RoleCreate(name=f"role-{uuid.uuid4().hex[:8]}"))
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
privilege = await rbac_repo.get_privilege_by_name("Project.Audit")
await rbac_repo.add_privilege_to_role(role.role_id, privilege)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
async def test_pool_membership_change_is_visible(self, test_user: User, db_session: AsyncSession) -> None:
project_id = str(uuid.uuid4())
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
pools_repo = ResourcePoolsRepository(db_session)
pool = await pools_repo.create_resource_pool(ResourcePoolCreate(name=f"pool-{uuid.uuid4().hex[:8]}"))
resource = await pools_repo.create_resource(
ResourceCreate(resource_id=uuid.UUID(project_id), resource_type="project", name=f"project-{project_id[:8]}")
)
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(
path=f"/pools/{pool.resource_pool_id}",
ace_type="group",
propagate=False,
group_id=str(group_id),
role_id=str(role.role_id),
)
)
# the resource is not in the pool yet
assert (
await rbac_repo.check_user_has_privilege(test_user.user_id, f"/projects/{project_id}", "Project.Audit")
is False
)
await pools_repo.add_resource_to_pool(pool.resource_pool_id, resource)
assert (
await rbac_repo.check_user_has_privilege(test_user.user_id, f"/projects/{project_id}", "Project.Audit")
is True
)
async def test_user_ace_denies_group_ace(self, test_user: User, db_session: AsyncSession) -> None:
path = f"/projects/{uuid.uuid4()}"
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
# a deny ACE on the user itself replaces the group privileges
await rbac_repo.create_ace(
ACECreate(
path=path,
ace_type="user",
propagate=False,
allowed=False,
user_id=str(test_user.user_id),
role_id=str(role.role_id),
)
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
class TestRbacStateReload:
async def test_dirty_flag_cycle(self, test_user: User, db_session: AsyncSession) -> None:
state = RbacState.instance()
state.reset()
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
path = f"/projects/{uuid.uuid4()}"
await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit")
assert state._loaded is True
assert state._dirty is False
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert state._dirty is True
# the next check reloads the snapshot and clears the flag
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
assert state._dirty is False
async def test_reset_forces_reload_from_database(self, test_user: User, db_session: AsyncSession) -> None:
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
path = f"/projects/{uuid.uuid4()}"
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
# a reset drops the in-memory data; the next check reloads from the
# database and the ACE is still there
RbacState.instance().reset()
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
async def test_startup_helper_loads_snapshot(
self, app: FastAPI, db_engine, test_user: User, db_session: AsyncSession
) -> None:
from gns3server.db.tasks import load_rbac_state
RbacState.instance().reset()
app.state._db_engine = db_engine
await load_rbac_state(app)
assert RbacState.instance()._loaded is True
assert RbacState.instance()._dirty is False
@@ -222,22 +222,6 @@ async def test_external_symlinks_hidden_files_and_libraries_are_not_imported(inv
assert outside.read_bytes() == QCOW
async def test_non_image_files_next_to_images_are_ignored(inventory, config):
"""Files a user keeps next to images (scripts, notes, licenses) must not be
reported as invalid images nor turn the sync job partial."""
image_file(config, "IOU/CiscoIOUKeygen.py", b"#!/usr/bin/env python\ndef main(): ...")
image_file(config, "IOU/README.md", b"# IOU images")
image_file(config, "QEMU/notes.TXT", b"my qemu notes")
image_file(config, "IOS/license.lic", b"license text")
result = await scan(inventory)
assert result["counts"]["scanned"] == 0
assert result["counts"]["invalid"] == 0
assert result["counts"]["errors"] == 0
assert result["errors"] == []
assert result["status"] == "completed"
@pytest.mark.parametrize(
"name,data,expected",
[
+1 -7
View File
@@ -23,13 +23,7 @@ import pytest
from gns3server.config import Config
from gns3server.main import parse_arguments as parse
from gns3server.server import GRACEFUL_SHUTDOWN_TIMEOUT, Server
def test_uvicorn_config_bounds_graceful_shutdown(config):
uvicorn_config = Server._create_uvicorn_config(None, "127.0.0.1", 3080, False)
assert uvicorn_config.timeout_graceful_shutdown == GRACEFUL_SHUTDOWN_TIMEOUT
from gns3server.server import Server
def test_locale_check():
+1 -30
View File
@@ -20,13 +20,7 @@ import threading
import pytest
from gns3server.utils import force_unix_path
from gns3server.utils.images import (
canonical_docker_image_name,
images_directories,
list_images,
md5sum,
remove_checksum,
)
from gns3server.utils.images import images_directories, list_images, md5sum, remove_checksum
def test_images_directories(tmpdir, config):
@@ -114,29 +108,6 @@ def test_remove_checksum(tmpdir):
remove_checksum(str(tmpdir / "not_exists"))
@pytest.mark.parametrize(
"reference, expected",
(
("alpine", "library/alpine:latest"),
("alpine:3", "library/alpine:3"),
("library/alpine:3", "library/alpine:3"),
("docker.io/library/alpine:3", "library/alpine:3"),
("index.docker.io/library/alpine:3", "library/alpine:3"),
("localhost/alpine:3", "library/alpine:3"),
("gns3/frr", "gns3/frr:latest"),
("gns3/frr:latest", "gns3/frr:latest"),
("quay.io/some/namespace/image:2.0", "some/namespace/image:2.0"),
("alpine@sha256:" + "a" * 64, "library/alpine@sha256:" + "a" * 64),
("alpine:3@sha256:" + "a" * 64, "library/alpine@sha256:" + "a" * 64),
),
)
def test_canonical_docker_image_name(reference, expected):
assert canonical_docker_image_name(reference) == expected
# the canonical form is stable: canonicalizing twice does not change it
assert canonical_docker_image_name(expected) == expected
@pytest.mark.asyncio
async def test_list_images(tmpdir, config):