mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-05 23:01:15 +02:00
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:
commit
42d30651da
497 files changed
+22962
-24152
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user