Merge origin/3.1 into image-manager-out-of-sync

Preserve image reconciliation, locking, and regression coverage while integrating upstream release, dependency, typing, and formatting updates. Align feature code with the new Ruff checks.
This commit is contained in:
Cristi committed 2026-09-28 23:16:24 +03:00
commit 42d30651da
497 files changed
+22962 -24152

No files matched your search

+72 -72
View File
@@ -33,7 +33,13 @@ from typing import List, Optional, Literal
from gns3server import schemas
from gns3server.config import Config
from gns3server.compute.qemu import Qemu
from gns3server.utils.images import InvalidImageError, write_image, read_image_info, default_images_directory, get_builtin_disks
from gns3server.utils.images import (
InvalidImageError,
write_image,
read_image_info,
default_images_directory,
get_builtin_disks,
)
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.db.repositories.rbac import RbacRepository
@@ -44,7 +50,7 @@ from gns3server.controller.controller_error import (
ControllerError,
ControllerNotFoundError,
ControllerForbiddenError,
ControllerBadRequestError
ControllerBadRequestError,
)
from .dependencies.authentication import get_current_active_user
@@ -64,8 +70,12 @@ def image_destination(image_path):
return full_path
@router.post("/sync", response_model=schemas.ImageSyncJob, status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(has_privilege("Image.Allocate"))])
@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:
@@ -76,27 +86,29 @@ async def sync_images(options: schemas.ImageSyncRequest, request: Request, respo
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)):
@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,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Image.Allocate"))]
dependencies=[Depends(has_privilege("Image.Allocate"))],
)
async def create_qemu_image(
image_path: str,
image_data: schemas.QemuDiskImageCreate,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_path: str,
image_data: schemas.QemuDiskImageCreate,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> schemas.Image:
"""
Create a new blank Qemu image.
@@ -130,7 +142,9 @@ async def create_qemu_image(
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))
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
@@ -138,15 +152,12 @@ async def create_qemu_image(
if os.path.exists(temporary):
os.unlink(temporary)
@router.get(
"",
response_model=List[schemas.Image],
dependencies=[Depends(has_privilege("Image.Audit"))]
)
@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
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_type: Optional[schemas.ImageType] = None,
availability: Optional[Literal["unknown", "available", "missing", "unavailable", "invalid"]] = None,
) -> List[schemas.Image]:
"""
Return all images.
@@ -161,16 +172,16 @@ async def get_images(
"/upload/{image_path:path}",
response_model=schemas.Image,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Image.Allocate"))]
dependencies=[Depends(has_privilege("Image.Allocate"))],
)
async def upload_image(
image_path: str,
request: Request,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
install_appliances: Optional[bool] = False,
image_path: str,
request: Request,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
install_appliances: Optional[bool] = False,
) -> schemas.Image:
"""
Upload an image.
@@ -193,9 +204,7 @@ async def upload_image(
duplicate = await images_repo.get_image_by_checksum(checksum_header, check_dir)
if duplicate:
location = f" in '{check_dir}'" if check_dir else ""
raise ControllerError(
f"Image '{duplicate.filename}' with the same checksum already exists{location}"
)
raise ControllerError(f"Image '{duplicate.filename}' with the same checksum already exists{location}")
try:
allow_raw_image = Config.instance().settings.Server.allow_raw_images
@@ -215,19 +224,17 @@ async def upload_image(
templates_repo,
rbac_repo,
current_user,
os.path.dirname(image.path)
os.path.dirname(image.path),
)
return image
@router.delete(
"/prune",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Image.Allocate"))]
"/prune", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Image.Allocate"))]
)
async def prune_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> None:
"""
Prune images not attached to any template.
@@ -242,18 +249,14 @@ async def prune_images(
# referenced file name at once
referenced_filenames = Controller.instance().collect_referenced_image_filenames()
await images_repo.prune_images(
list(skip_images) + list(referenced_filenames),
is_in_use=Controller.instance().find_projects_using_image)
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"))]
)
@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))])
async def install_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> dict:
"""
Attempt to automatically create templates based on image checksums.
@@ -270,7 +273,9 @@ async def install_images(
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"})
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")
@@ -279,19 +284,15 @@ async def install_images(
if templates:
# the image is already used by a template
log.warning(f"Image '{image.path}' is used by one or more templates")
skipped.append({
"name": image.filename,
"reason": "image is already used by one or more templates",
})
skipped.append(
{
"name": image.filename,
"reason": "image is already used by one or more templates",
}
)
continue
results = await Controller.instance().appliance_manager.install_appliances_from_image(
image.path,
image.checksum,
images_repo,
templates_repo,
None,
None,
os.path.dirname(image.path)
image.path, image.checksum, images_repo, templates_repo, None, None, os.path.dirname(image.path)
)
for result in results:
if result.get("status") == "created":
@@ -301,14 +302,10 @@ async def install_images(
return {"created": created, "skipped": skipped}
@router.get(
"/{image_path:path}",
response_model=schemas.Image,
dependencies=[Depends(has_privilege("Image.Audit"))]
)
@router.get("/{image_path:path}", response_model=schemas.Image, dependencies=[Depends(has_privilege("Image.Audit"))])
async def get_image(
image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> schemas.Image:
"""
Return an image.
@@ -326,11 +323,11 @@ async def get_image(
@router.delete(
"/{image_path:path}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Image.Allocate"))]
dependencies=[Depends(has_privilege("Image.Allocate"))],
)
async def delete_image(
image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> None:
"""
Delete an image.
@@ -343,8 +340,9 @@ async def delete_image(
try:
image = await images_repo.get_image(image_path)
except MultipleResultsFound:
raise ControllerBadRequestError(f"Image '{image_path}' matches multiple images. "
f"Please include the absolute path of the image")
raise ControllerBadRequestError(
f"Image '{image_path}' matches multiple images. Please include the absolute path of the image"
)
if not image:
raise ControllerNotFoundError(f"Image '{image_path}' not found")
@@ -365,7 +363,9 @@ async def delete_image(
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):
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)