Merge branch '3.1' into ruff/trivia-fixups

This commit is contained in:
Mark Paronyan committed 2026-10-01 19:58:02 +03:00
commit a77e06b907
24 files changed
+2026 -334

No files matched your search

+100 -39
View File
@@ -21,12 +21,14 @@ API routes for images.
import os
import logging
import urllib.parse
import tempfile
from fastapi import APIRouter, Request, Depends, status
from fastapi import APIRouter, Request, Response, Depends, Query, status
from fastapi.encoders import jsonable_encoder
from starlette.requests import ClientDisconnect
from sqlalchemy.orm.exc import MultipleResultsFound
from typing import List, Optional
from sqlalchemy.exc import SQLAlchemyError
from typing import List, Optional, Literal
from gns3server import schemas
from gns3server.config import Config
@@ -43,6 +45,8 @@ from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.controller import Controller
from gns3server.services.image_reconciliation import get_image_reconciliation_service
from gns3server.utils.image_inventory import contained_path, image_lock, publish_image, fingerprint, ImageLockBusy
from gns3server.controller.controller_error import (
ControllerError,
ControllerNotFoundError,
@@ -59,6 +63,43 @@ log = logging.getLogger(__name__)
router = APIRouter()
def image_destination(image_path):
root = os.path.realpath(os.path.expanduser(Config.instance().settings.Server.images_path))
full_path = os.path.abspath(os.path.join(root, image_path))
if not contained_path(os.path.realpath(full_path), root):
raise ControllerForbiddenError(f"Cannot write image, '{image_path}' is forbidden")
return full_path
@router.post(
"/sync",
response_model=schemas.ImageSyncJob,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(has_privilege("Image.Allocate"))],
)
async def sync_images(options: schemas.ImageSyncRequest, request: Request, response: Response):
"""Reconcile configured image directories without deleting files or references."""
try:
job = await get_image_reconciliation_service(request.app).start(**options.model_dump())
except ImageLockBusy:
raise ControllerError("Image synchronization is already running or shutting down")
response.headers["Location"] = str(request.url_for("get_image_sync_job", job_id=job["job_id"]))
return job
@router.get(
"/sync/jobs/{job_id}", response_model=schemas.ImageSyncJob, dependencies=[Depends(has_privilege("Image.Audit"))]
)
async def get_image_sync_job(
job_id: str, request: Request, offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000)
):
"""Get progress and a page of errors (first 1,000 errors retained per job)."""
job = await get_image_reconciliation_service(request.app).get_job(job_id, offset, limit)
if job is None:
raise ControllerNotFoundError(f"Image synchronization job '{job_id}' not found")
return job
@router.post(
"/qemu/{image_path:path}",
response_model=schemas.Image,
@@ -69,7 +110,7 @@ async def create_qemu_image(
image_path: str,
image_data: schemas.QemuDiskImageCreate,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> models.Image:
) -> Optional[models.Image]:
"""
Create a new blank Qemu image.
@@ -83,39 +124,41 @@ async def create_qemu_image(
disk_image_path = urllib.parse.unquote(image_path)
image_dir, image_name = os.path.split(disk_image_path)
# check if the path is within the default images directory
base_images_directory = os.path.expanduser(Config.instance().settings.Server.images_path)
full_path = os.path.abspath(os.path.join(base_images_directory, image_dir, image_name))
if os.path.commonprefix([base_images_directory, full_path]) != base_images_directory:
raise ControllerForbiddenError(f"Cannot write disk image, '{disk_image_path}' is forbidden")
disk_image_path = image_destination(disk_image_path)
if not image_dir:
# put the image in the default images directory for Qemu
directory = default_images_directory(image_type="qemu")
os.makedirs(directory, exist_ok=True)
disk_image_path = os.path.abspath(os.path.join(directory, disk_image_path))
disk_image_path = image_destination(os.path.join(directory, image_name))
if await images_repo.get_image(disk_image_path):
raise ControllerBadRequestError(f"Disk image '{disk_image_path}' already exists")
options = jsonable_encoder(image_data, exclude_unset=True)
# FIXME: should we have the create_disk_image in the compute code since
# this code is used to create images on the controller?
await Qemu.instance().create_disk_image(disk_image_path, options)
image_info = await read_image_info(disk_image_path, "qemu")
image = await images_repo.get_image(disk_image_path)
if image:
# the image has already been added to the database
return image
else:
return await images_repo.add_image(**image_info)
async with image_lock(disk_image_path):
if os.path.lexists(disk_image_path):
raise ControllerBadRequestError(f"Disk image '{disk_image_path}' already exists")
os.makedirs(os.path.dirname(disk_image_path), exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".gns3-create-", suffix=".tmp", dir=os.path.dirname(disk_image_path))
os.close(fd)
try:
options = jsonable_encoder(image_data, exclude_unset=True)
await Qemu.instance().create_disk_image(temporary, options)
image_info = await read_image_info(temporary, "qemu", allow_raw_image=allow_raw_image)
publish_image(temporary, disk_image_path)
image_info.update(
path=disk_image_path, image_name=image_name, file_fingerprint=fingerprint(disk_image_path)
)
return await images_repo.save_verified_image(image_info)
except (OSError, InvalidImageError, SQLAlchemyError) as e:
raise ControllerError(f"Could not create disk image '{disk_image_path}': {e}") from e
finally:
if os.path.exists(temporary):
os.unlink(temporary)
@router.get("", response_model=List[schemas.Image], dependencies=[Depends(has_privilege("Image.Audit"))])
async def get_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_type: Optional[schemas.ImageType] = None,
availability: Optional[Literal["unknown", "available", "missing", "unavailable", "invalid"]] = None,
) -> List[models.Image]:
"""
Return all images.
@@ -123,7 +166,7 @@ async def get_images(
Required privilege: Image.Audit
"""
return await images_repo.get_images(image_type)
return await images_repo.get_images(image_type, availability)
@router.post(
@@ -153,10 +196,7 @@ async def upload_image(
image_path = urllib.parse.unquote(image_path)
image_dir, image_name = os.path.split(image_path)
# check if the path is within the default images directory
base_images_directory = os.path.expanduser(Config.instance().settings.Server.images_path)
full_path = os.path.abspath(os.path.join(base_images_directory, image_dir, image_name))
if os.path.commonprefix([base_images_directory, full_path]) != base_images_directory:
raise ControllerForbiddenError(f"Cannot write image, '{image_path}' is forbidden")
full_path = image_destination(image_path)
# If the client sends X-MD5-Checksum, check for a duplicate before consuming the upload stream
checksum_header = request.headers.get("X-MD5-Checksum")
@@ -170,7 +210,10 @@ async def upload_image(
try:
allow_raw_image = Config.instance().settings.Server.allow_raw_images
image = await write_image(image_path, full_path, request.stream(), images_repo, allow_raw_image=allow_raw_image)
except (OSError, InvalidImageError, ClientDisconnect) as e:
except (OSError, InvalidImageError, ClientDisconnect, SQLAlchemyError) as e:
service = getattr(request.app.state, "image_reconciliation", None)
if service:
service.dirty.set()
raise ControllerError(f"Could not save image '{image_path}': {e}")
if install_appliances:
@@ -206,7 +249,9 @@ async def prune_images(
# a single pass over all projects' node properties protects every
# referenced file name at once
referenced_filenames = Controller.instance().collect_referenced_image_filenames()
await images_repo.prune_images(list(skip_images) + list(referenced_filenames))
await images_repo.prune_images(
list(skip_images) + list(referenced_filenames), is_in_use=Controller.instance().find_projects_using_image
)
@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))])
@@ -228,6 +273,11 @@ async def install_images(
skip_images = get_builtin_disks()
images = await images_repo.get_images()
for image in images:
if not await images_repo.is_usable(image):
skipped.append(
{"name": image.filename, "reason": "image is missing, changed or unreadable; synchronize images first"}
)
continue
if skip_images and image.filename in skip_images:
log.debug(f"Skipping image '{image.path}' for image installation")
continue
@@ -307,11 +357,22 @@ async def delete_image(
if project_names:
raise ControllerError(f"Image '{image_path}' is used by one or more projects: {', '.join(project_names)}")
try:
os.remove(image.path)
except OSError:
log.warning(f"Could not delete image file {image.path}")
success = await images_repo.delete_image(image_path)
if not success:
raise ControllerError(f"Image '{image_path}' could not be deleted")
path = image.path
revision = (image.image_id, image.checksum, image.file_fingerprint)
async with image_lock(path):
image = await images_repo.get_image(path, refresh=True)
if image is None or (image.image_id, image.checksum, image.file_fingerprint) != revision:
raise ControllerError(f"Image '{image_path}' changed while waiting for deletion; refresh and retry")
# Recheck usage after waiting for a concurrent writer/scanner.
if await images_repo.get_image_templates(image.image_id) or Controller.instance().find_projects_using_image(
image.filename
):
raise ControllerError(f"Image '{image_path}' is in use")
try:
os.remove(image.path)
except FileNotFoundError:
pass
except OSError as e:
raise ControllerError(f"Could not delete image file '{image.path}': {e}") from e
if not await images_repo.delete_image_exact(image.image_id):
raise ControllerError(f"Image '{image_path}' could not be deleted")