mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-12 18:05:54 +02:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd82f3b6c | ||
|
|
89b88a5e88 | ||
|
|
c24e332d79 | ||
|
|
dbee7110da | ||
|
|
5e30ce4b13 | ||
|
|
e142aa68bd | ||
|
|
4f79fc21db | ||
|
|
353ca2ec93 | ||
|
|
dd429e9385 | ||
|
|
6891e7e66f | ||
|
|
984bf4232d | ||
|
|
ce8d5591a1 | ||
|
|
c066434959 | ||
|
|
653ea386a5 | ||
|
|
076c949a4e | ||
|
|
01c20adac5 | ||
|
|
fcf8490d90 | ||
|
|
f8eedae9fc | ||
|
|
0914f44273 | ||
|
|
f87f20cb27 | ||
|
|
2244d730bb | ||
|
|
c846c2ae90 | ||
|
|
de986e890e | ||
|
|
04a8ba06a8 | ||
|
|
d5d25eba06 | ||
|
|
8c98142d26 | ||
|
|
424ba919d8 | ||
|
|
54cfcef6e2 | ||
|
|
c1e03194d3 | ||
|
|
4c00d223e4 | ||
|
|
092202615e | ||
|
|
111a4b756c | ||
|
|
c80bea5279 | ||
|
|
425d91c085 | ||
|
|
3ddfe0254d | ||
|
|
94077b4884 | ||
|
|
2e5e761c3e | ||
|
|
ac54b6d717 | ||
|
|
ddd1f61b6a | ||
|
|
0a04d084cc | ||
|
|
0331cfd2f3 | ||
|
|
d6e70da9ff | ||
|
|
5943887031 | ||
|
|
b539793281 | ||
|
|
c327055703 | ||
|
|
9429dad9d0 | ||
|
|
2480fb669e | ||
|
|
70155eac83 | ||
|
|
e7b901de30 | ||
|
|
6a7c9e4417 | ||
|
|
517b0b0d85 | ||
|
|
7230e86c15 | ||
|
|
70ef92b55f | ||
|
|
bbb1dc4f80 | ||
|
|
a56fce4c36 | ||
|
|
8f763c0439 | ||
|
|
52c19002f2 |
No files matched your search
+9
-9
@@ -8,20 +8,20 @@
|
||||
# Core AI and Automation Framework
|
||||
# Note: aiosqlite is in core requirements.txt
|
||||
langchain>=1.2.10
|
||||
langchain-core>=1.2.16
|
||||
langgraph>=1.0.9
|
||||
langchain-core>=1.7.3
|
||||
langgraph>=1.2.19
|
||||
langgraph-checkpoint>=4.0.0
|
||||
langgraph-checkpoint-sqlite>=3.1.2
|
||||
langgraph-checkpoint-sqlite>=3.1.3
|
||||
langgraph-checkpoint-postgres>=3.0.4
|
||||
|
||||
# Model Providers
|
||||
langchain-openai>=1.7.2
|
||||
langchain-anthropic>=1.3.4
|
||||
langchain-openai>=1.7.3
|
||||
langchain-anthropic>=1.7.10
|
||||
langchain-google-genai>=4.4.2
|
||||
langchain-aws>=1.9.0
|
||||
langchain-ollama>=1.1.0
|
||||
langchain-deepseek>=1.1.1
|
||||
langchain-xai>=1.2.2
|
||||
langchain-xai>=1.3.0
|
||||
|
||||
# Token Counting
|
||||
tiktoken>=0.14.0
|
||||
@@ -30,15 +30,15 @@ tiktoken>=0.14.0
|
||||
langsmith>=0.14.7
|
||||
|
||||
# Network Automation
|
||||
paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405
|
||||
paramiko>=5.1.0 # install version >= 5.0.0 to address CVE-2026-44405
|
||||
netmiko>=4.8.0
|
||||
nornir>=3.6.0
|
||||
nornir>=3.6.1
|
||||
nornir-netmiko>=1.0.1
|
||||
nornir-utils>=0.3.0
|
||||
nornir-salt>=0.23.4
|
||||
|
||||
# Environment & Configuration
|
||||
python-dotenv>=1.2.1
|
||||
python-dotenv>=1.2.4
|
||||
|
||||
# Authentication
|
||||
PyJWT>=2.15.1
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"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
|
||||
{"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
|
||||
@@ -67,6 +67,7 @@ compute_api = FastAPI(
|
||||
)
|
||||
|
||||
compute_api.state.controller_host = None
|
||||
compute_api.state.exiting = False
|
||||
|
||||
|
||||
@compute_api.exception_handler(ComputeError)
|
||||
|
||||
@@ -19,16 +19,24 @@ API routes for images.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request, status
|
||||
from fastapi import APIRouter, Body, HTTPException, Path, 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()
|
||||
|
||||
@@ -44,13 +52,55 @@ 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+$")) -> None:
|
||||
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:
|
||||
"""
|
||||
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()
|
||||
await docker_manager.pull_image(image, force=True)
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -73,6 +123,27 @@ 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 True:
|
||||
while not websocket.app.state.exiting:
|
||||
notification = await queue.get_json(5)
|
||||
await websocket.send_text(notification)
|
||||
except (ConnectionClosed, WebSocketDisconnect):
|
||||
|
||||
@@ -203,9 +203,6 @@ 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)
|
||||
|
||||
@@ -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, Query, status
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
|
||||
|
||||
import gns3server.db.models as models
|
||||
from gns3server import schemas
|
||||
@@ -29,7 +29,13 @@ 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
|
||||
@@ -215,16 +221,111 @@ 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+$")
|
||||
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"
|
||||
),
|
||||
) -> 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))
|
||||
await compute.forward("POST", "docker", "images/pull", data={"image": image})
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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 True:
|
||||
while not websocket.app.state.exiting:
|
||||
notification = await queue.get_json(5)
|
||||
await websocket.send_text(notification)
|
||||
except (ConnectionClosed, WebSocketDisconnect):
|
||||
|
||||
@@ -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"/groups/{user_group_id}")
|
||||
await rbac_repo.delete_all_ace_starting_with_path(f"/access/groups/{user_group_id}")
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -126,10 +126,12 @@ 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 == current_user.username:
|
||||
if p.id in direct_ace_ids and (p.created_by is None or p.created_by == current_user.username):
|
||||
if p.id not in seen_project_ids:
|
||||
projects.append(p.asdict())
|
||||
seen_project_ids.add(p.id)
|
||||
@@ -189,6 +191,46 @@ 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,
|
||||
@@ -659,7 +701,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 True:
|
||||
while not websocket.app.state.exiting:
|
||||
notification = await queue.get_json(5)
|
||||
await websocket.send_text(notification)
|
||||
except (ConnectionClosed, WebSocketDisconnect):
|
||||
@@ -701,7 +743,7 @@ async def project_marker_ws_notifications(
|
||||
)
|
||||
try:
|
||||
with controller.notification.project_marker_queue(project.id) as queue:
|
||||
while True:
|
||||
while not websocket.app.state.exiting:
|
||||
notification = await queue.get_json(5)
|
||||
await websocket.send_text(notification)
|
||||
except (ConnectionClosed, WebSocketDisconnect):
|
||||
@@ -884,7 +926,11 @@ 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)
|
||||
new_project = await project.duplicate(
|
||||
name=project_data.name,
|
||||
reset_mac_addresses=reset_mac_addresses,
|
||||
user=current_user.username,
|
||||
)
|
||||
|
||||
# Add the new project in the same resource pools if the duplicated project belongs to any
|
||||
if pool_memberships:
|
||||
|
||||
@@ -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"/roles/{role_id}")
|
||||
await rbac_repo.delete_all_ace_starting_with_path(f"/access/roles/{role_id}")
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -165,6 +165,31 @@ 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]:
|
||||
"""
|
||||
@@ -265,7 +290,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"/users/{user_id}")
|
||||
await rbac_repo.delete_all_ace_starting_with_path(f"/access/users/{user_id}")
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -134,6 +134,8 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -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,10 +49,9 @@ 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(BaseManager):
|
||||
class Docker(DockerImagesMixin, BaseManager):
|
||||
_NODE_CLASS = DockerVM
|
||||
|
||||
def __init__(self):
|
||||
@@ -291,7 +290,8 @@ class Docker(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: Timeout
|
||||
: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
|
||||
:returns: HTTP response
|
||||
"""
|
||||
|
||||
@@ -302,7 +302,7 @@ class Docker(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 = 60 * 60 * 24 * 31 # One month timeout
|
||||
timeout = aiohttp.ClientTimeout(total=None)
|
||||
|
||||
if path == "version":
|
||||
url = "http://docker/" + path
|
||||
@@ -354,177 +354,3 @@ class Docker(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"])
|
||||
@@ -0,0 +1,395 @@
|
||||
#
|
||||
# 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)
|
||||
@@ -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):
|
||||
async def forward(self, method, type, path, data=None, params=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)
|
||||
res = await self.http_query(method, action, data=data, timeout=None, params=params)
|
||||
except aiohttp.ServerDisconnectedError:
|
||||
raise ControllerError(f"Connection lost to {self._id} during {method} {action}")
|
||||
return res.json
|
||||
|
||||
@@ -126,6 +126,12 @@ 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:
|
||||
|
||||
@@ -2158,7 +2158,7 @@ class Project:
|
||||
while self._loading:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def duplicate(self, name=None, reset_mac_addresses=True):
|
||||
async def duplicate(self, name=None, reset_mac_addresses=True, user=None):
|
||||
"""
|
||||
Duplicate a project
|
||||
|
||||
@@ -2169,6 +2169,7 @@ 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
|
||||
@@ -2180,7 +2181,7 @@ class Project:
|
||||
assert self._status != "closed"
|
||||
|
||||
try:
|
||||
proj = await self._fast_duplication(name, reset_mac_addresses)
|
||||
proj = await self._fast_duplication(name, reset_mac_addresses=reset_mac_addresses, user=user)
|
||||
if proj:
|
||||
if previous_status == "closed":
|
||||
await self.close()
|
||||
@@ -2222,6 +2223,7 @@ 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")
|
||||
@@ -2233,7 +2235,7 @@ class Project:
|
||||
|
||||
return project
|
||||
|
||||
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True):
|
||||
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True, user=None):
|
||||
"""
|
||||
Fast duplication of a project.
|
||||
|
||||
@@ -2242,6 +2244,7 @@ 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
|
||||
@@ -2280,6 +2283,9 @@ 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
from gns3server.db.tasks import connect_to_db, disconnect_from_db, get_computes, load_rbac_state
|
||||
from gns3server.services.image_reconciliation import get_image_reconciliation_service
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
|
||||
@@ -74,6 +74,9 @@ 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()
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
#
|
||||
# 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
|
||||
@@ -26,6 +26,7 @@ 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
|
||||
|
||||
@@ -36,6 +37,7 @@ 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]:
|
||||
"""
|
||||
@@ -65,6 +67,7 @@ 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
|
||||
|
||||
@@ -76,6 +79,7 @@ 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]:
|
||||
@@ -127,6 +131,7 @@ 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
|
||||
|
||||
@@ -146,6 +151,7 @@ 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
|
||||
@@ -167,6 +173,7 @@ 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(
|
||||
@@ -188,6 +195,7 @@ 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
|
||||
|
||||
@@ -210,6 +218,7 @@ 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
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional, Union, cast
|
||||
from urllib.parse import urlparse
|
||||
from typing import List, Optional, Set, Union, cast
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, delete, select, update
|
||||
from sqlalchemy import 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,6 +37,7 @@ 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]:
|
||||
"""
|
||||
@@ -76,6 +77,7 @@ 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]:
|
||||
@@ -88,6 +90,7 @@ 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
|
||||
@@ -101,6 +104,7 @@ 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]:
|
||||
@@ -123,6 +127,7 @@ 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
|
||||
|
||||
@@ -139,6 +144,7 @@ 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
|
||||
|
||||
@@ -179,6 +185,18 @@ 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.
|
||||
@@ -239,6 +257,7 @@ 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
|
||||
|
||||
@@ -252,6 +271,7 @@ 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
|
||||
@@ -265,6 +285,7 @@ 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:
|
||||
@@ -274,109 +295,13 @@ 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:
|
||||
@@ -384,72 +309,8 @@ class RbacRepository(BaseRepository):
|
||||
- pool_accessible_ids: projects in resource pools the user can access (no created_by filter)
|
||||
"""
|
||||
|
||||
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
|
||||
await self._rbac_state.ensure_loaded(self._db_session)
|
||||
return self._rbac_state.accessible_project_ids(user_id, privilege_name, all_project_ids)
|
||||
|
||||
async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
|
||||
"""
|
||||
@@ -461,40 +322,10 @@ 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.
|
||||
"""
|
||||
|
||||
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
|
||||
await self._rbac_state.ensure_loaded(self._db_session)
|
||||
return self._rbac_state.user_has_privilege(user_id, path, privilege_name)
|
||||
@@ -26,6 +26,7 @@ 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
|
||||
@@ -38,6 +39,7 @@ 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]:
|
||||
"""
|
||||
@@ -86,6 +88,7 @@ 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
|
||||
|
||||
@@ -105,6 +108,7 @@ 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
|
||||
@@ -132,6 +136,7 @@ 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]:
|
||||
@@ -208,6 +213,7 @@ 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
|
||||
|
||||
@@ -223,6 +229,7 @@ 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
|
||||
@@ -236,6 +243,7 @@ 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]:
|
||||
@@ -255,6 +263,7 @@ 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
|
||||
|
||||
@@ -277,6 +286,7 @@ 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
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -199,6 +200,16 @@ 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
|
||||
|
||||
@@ -71,6 +71,7 @@ from .controller.projects import (
|
||||
ProjectCreate,
|
||||
ProjectDuplicate,
|
||||
ProjectFile,
|
||||
ProjectPrivileges,
|
||||
ProjectStats,
|
||||
ProjectUpdate,
|
||||
)
|
||||
@@ -83,6 +84,7 @@ from .controller.users import (
|
||||
UserGroup,
|
||||
UserGroupCreate,
|
||||
UserGroupUpdate,
|
||||
UserPrivileges,
|
||||
UserUpdate,
|
||||
)
|
||||
from .version import Version
|
||||
@@ -350,6 +352,7 @@ __all__ = [
|
||||
"ProjectDuplicate",
|
||||
"ProjectFile",
|
||||
"ProjectMarker",
|
||||
"ProjectPrivileges",
|
||||
"ProjectStats",
|
||||
"ProjectUpdate",
|
||||
"Qemu",
|
||||
@@ -397,6 +400,7 @@ __all__ = [
|
||||
"UserGroup",
|
||||
"UserGroupCreate",
|
||||
"UserGroupUpdate",
|
||||
"UserPrivileges",
|
||||
"UserUpdate",
|
||||
"VMware",
|
||||
"VMwareCreate",
|
||||
|
||||
@@ -146,6 +146,9 @@ 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):
|
||||
|
||||
@@ -139,3 +139,18 @@ 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]
|
||||
@@ -15,7 +15,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, SecretStr
|
||||
@@ -69,6 +69,20 @@ 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.
|
||||
|
||||
+25
-9
@@ -42,6 +42,12 @@ 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
|
||||
@@ -222,6 +228,24 @@ 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)
|
||||
@@ -301,15 +325,7 @@ class Server:
|
||||
if config.Server.enable_ssl:
|
||||
log.info("SSL is enabled")
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
access_log=access_log,
|
||||
ssl_certfile=config.Server.certfile,
|
||||
ssl_keyfile=config.Server.certkey,
|
||||
lifespan="on",
|
||||
)
|
||||
config = self._create_uvicorn_config(app, host, port, access_log)
|
||||
|
||||
# overwrite uvicorn loggers with our own logger
|
||||
for uvicorn_logger_name in ("uvicorn", "uvicorn.error"):
|
||||
|
||||
@@ -47,8 +47,34 @@ 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"))
|
||||
return (
|
||||
name.startswith(".")
|
||||
or name.endswith((".tmp", ".md5sum"))
|
||||
or os.path.splitext(name)[1].lower() in IGNORED_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def configured_roots():
|
||||
|
||||
@@ -53,6 +53,62 @@ 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):
|
||||
"""
|
||||
|
||||
+2
-1
@@ -180,7 +180,8 @@ 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", "C901"]
|
||||
"gns3server/compute/docker/__init__.py" = ["B006"]
|
||||
"gns3server/compute/docker/docker_images.py" = ["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
@@ -3,7 +3,7 @@ uvicorn==0.54.0
|
||||
pydantic==2.14.2
|
||||
fastapi==0.162.0
|
||||
python-multipart==0.0.33
|
||||
websockets==16.1.1 # google-genai 2.10.0 has requirement websockets<17.0
|
||||
websockets==17.4
|
||||
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.6
|
||||
sqlalchemy[asyncio]==2.1.8
|
||||
aiosqlite==0.22.1
|
||||
alembic==1.20.1
|
||||
bcrypt==5.0.0
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -26,12 +27,75 @@ pytestmark = pytest.mark.asyncio
|
||||
class TestImagesRoutes:
|
||||
async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
|
||||
|
||||
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"}
|
||||
async def fake_pull(image, progress_callback=None, force=False):
|
||||
progress_callback(
|
||||
{"layers_total": 0, "layers_done": 0, "message": "Pulling 'nginx:latest'", "state": "started"}
|
||||
)
|
||||
mock.assert_called_once_with("nginx:latest", force=True)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
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"]
|
||||
|
||||
@pytest.mark.parametrize("image", ["", " ", "nginx latest"])
|
||||
async def test_pull_docker_image_rejects_invalid_name(
|
||||
@@ -55,6 +119,33 @@ 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
|
||||
|
||||
@@ -15,23 +15,30 @@
|
||||
# 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
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_notification_ws(compute_api):
|
||||
#
|
||||
# # FIXME: how to test websockets
|
||||
# pass
|
||||
import pytest
|
||||
|
||||
# with compute_api.ws("/notifications/ws") as ws:
|
||||
from gns3server.api.routes.compute import compute_api
|
||||
from gns3server.api.routes.compute.notifications import project_ws_notifications
|
||||
|
||||
# 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"
|
||||
|
||||
@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
|
||||
@@ -182,7 +182,10 @@ 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"}, {"image": "docker2"}]
|
||||
assert response.json() == [
|
||||
{"image": "docker1", "id": None, "size": None, "created": None},
|
||||
{"image": "docker2", "id": None, "size": None, "created": None},
|
||||
]
|
||||
|
||||
async def test_compute_pull_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
|
||||
|
||||
@@ -194,7 +197,188 @@ 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"})
|
||||
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__")
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
@@ -20,7 +20,9 @@ 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
|
||||
@@ -84,6 +86,36 @@ 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,14 +14,22 @@
|
||||
# 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"]
|
||||
@@ -86,3 +94,60 @@ 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
|
||||
@@ -0,0 +1,191 @@
|
||||
#
|
||||
# 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,6 +526,8 @@ 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
|
||||
@@ -556,6 +558,8 @@ 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:
|
||||
|
||||
|
||||
@@ -27,9 +27,12 @@ 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.users import User
|
||||
from gns3server.schemas.controller.rbac import ACECreate
|
||||
from gns3server.schemas.controller.users import User, UserCreate, UserGroupCreate
|
||||
from gns3server.services import auth_service
|
||||
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@@ -147,6 +150,36 @@ 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(
|
||||
@@ -711,3 +744,143 @@ 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
|
||||
@@ -16,13 +16,20 @@
|
||||
# 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
|
||||
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 tests.utils import AsyncioMagicMock, asyncio_patch
|
||||
|
||||
|
||||
@@ -132,11 +139,64 @@ 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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -165,7 +225,50 @@ 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")
|
||||
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
|
||||
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"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -188,7 +291,40 @@ 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()
|
||||
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -211,6 +347,24 @@ 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():
|
||||
|
||||
@@ -330,6 +484,125 @@ 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):
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ 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
|
||||
@@ -82,6 +83,10 @@ 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
|
||||
|
||||
@@ -640,7 +640,10 @@ 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)
|
||||
monkeypatch.setattr(gns3_app.state, "exiting", 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
|
||||
|
||||
async def fake_connect():
|
||||
compute._reconnect_attempted = True
|
||||
|
||||
@@ -119,6 +119,34 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -936,9 +936,10 @@ async def test_duplicate(project, controller):
|
||||
compute, "test", None, node_type="vmware", properties={"startup_config": "test.cfg"}
|
||||
)
|
||||
|
||||
new_project = await project.duplicate(name="Hello")
|
||||
new_project = await project.duplicate(name="Hello", user="user1")
|
||||
assert new_project.id != project.id
|
||||
assert new_project.name == "Hello"
|
||||
assert new_project.created_by == "user1"
|
||||
|
||||
await new_project.open()
|
||||
|
||||
@@ -946,6 +947,25 @@ 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
|
||||
|
||||
+102
-10
@@ -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
|
||||
from gns3server.schemas.controller.rbac import ACECreate, ACEUpdate
|
||||
from gns3server.services import auth_service
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -80,26 +80,57 @@ 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"
|
||||
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
|
||||
rbac_repo = RbacRepository(db_session)
|
||||
authorized = await rbac_repo.check_user_has_privilege(test_user.user_id, path, privilege)
|
||||
assert authorized is False
|
||||
|
||||
ace = await RbacRepository(db_session).get_ace_by_path("/projects")
|
||||
ace.propagate = True
|
||||
await db_session.commit()
|
||||
await self._get_or_create_projects_ace(db_session, propagate=True)
|
||||
|
||||
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
|
||||
authorized = await rbac_repo.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):
|
||||
|
||||
ace = await RbacRepository(db_session).get_ace_by_path("/projects")
|
||||
ace.allowed = False
|
||||
ace.propagate = True
|
||||
await db_session.commit()
|
||||
await self._get_or_create_projects_ace(db_session, propagate=True, allowed=False)
|
||||
|
||||
privilege = "Project.Audit"
|
||||
path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
|
||||
@@ -115,6 +146,27 @@ 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):
|
||||
@@ -221,6 +273,46 @@ 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:
|
||||
#
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#
|
||||
# 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,6 +222,22 @@ 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",
|
||||
[
|
||||
|
||||
@@ -23,7 +23,13 @@ import pytest
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.main import parse_arguments as parse
|
||||
from gns3server.server import Server
|
||||
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
|
||||
|
||||
|
||||
def test_locale_check():
|
||||
|
||||
@@ -20,7 +20,13 @@ import threading
|
||||
import pytest
|
||||
|
||||
from gns3server.utils import force_unix_path
|
||||
from gns3server.utils.images import images_directories, list_images, md5sum, remove_checksum
|
||||
from gns3server.utils.images import (
|
||||
canonical_docker_image_name,
|
||||
images_directories,
|
||||
list_images,
|
||||
md5sum,
|
||||
remove_checksum,
|
||||
)
|
||||
|
||||
|
||||
def test_images_directories(tmpdir, config):
|
||||
@@ -108,6 +114,29 @@ 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):
|
||||
|
||||
|
||||
Reference in new issue
Block a user