mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-05 23:01:15 +02:00
Merge branch '3.1' into ruff/trivia-fixups
This commit is contained in:
24 files changed
+2026
-334
No files matched your search
@@ -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")
|
||||
@@ -35,6 +35,7 @@ from ..utils.http_client import HTTPClient
|
||||
from .controller_error import ControllerBadRequestError, ControllerNotFoundError, ControllerError
|
||||
from .appliance_to_template import ApplianceToTemplate
|
||||
from ..utils.images import InvalidImageError, write_image, read_image_info
|
||||
from ..utils.image_inventory import image_lock
|
||||
|
||||
from gns3server import schemas
|
||||
from gns3server.schemas.controller.appliances import ApplianceModel
|
||||
@@ -178,13 +179,16 @@ class ApplianceManager:
|
||||
# check if the image is on disk but it not yet in the database
|
||||
image_path = os.path.join(image_dir, appliance_file)
|
||||
if os.path.exists(image_path):
|
||||
image_info = await read_image_info(image_path)
|
||||
if image_info.get("checksum") == image_checksum:
|
||||
log.info(f"Adding image '{image_path}' to the database")
|
||||
async with image_lock(image_path):
|
||||
image_info = await read_image_info(image_path, allow_raw_image=True)
|
||||
if image_info["checksum"] != image_checksum:
|
||||
raise ControllerError(
|
||||
f"Image '{image_path}' does not match the appliance checksum"
|
||||
)
|
||||
try:
|
||||
await images_repo.add_image(**image_info)
|
||||
await images_repo.save_verified_image(image_info)
|
||||
except SQLAlchemyError as e:
|
||||
log.warning(f"Error while adding image '{image['path']}' to the database: {e}")
|
||||
raise ControllerError(f"Could not register image '{image_path}': {e}") from e
|
||||
else:
|
||||
# download the image if there is a direct download URL
|
||||
direct_download_url = image.get("direct_download_url")
|
||||
|
||||
+11
-19
@@ -26,22 +26,23 @@ from gns3server.compute import MODULES
|
||||
from gns3server.compute.port_manager import PortManager
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
from gns3server.db.tasks import connect_to_db, get_computes, disconnect_from_db, discover_images_on_filesystem
|
||||
from gns3server.db.tasks import connect_to_db, get_computes, disconnect_from_db
|
||||
from gns3server.services.image_reconciliation import get_image_reconciliation_service
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
auto_discover_images_task_handle = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
await startup(app)
|
||||
yield
|
||||
await shutdown(app)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await shutdown(app)
|
||||
|
||||
|
||||
async def startup(app: FastAPI) -> None:
|
||||
@@ -66,18 +67,7 @@ async def startup(app: FastAPI) -> None:
|
||||
|
||||
await Controller.instance().start(computes)
|
||||
|
||||
# Because with a large image collection
|
||||
# without md5sum already computed we start the
|
||||
# computing with server start
|
||||
from gns3server.compute.qemu import Qemu
|
||||
|
||||
if Config.instance().settings.Server.auto_discover_images is True:
|
||||
# Start the discovering new images on file system 5 seconds after the server has started
|
||||
# to give it a chance to process API requests
|
||||
global auto_discover_images_task_handle
|
||||
auto_discover_images_task_handle = asyncio.get_event_loop().call_later(
|
||||
5, lambda: asyncio.create_task(discover_images_on_filesystem(app))
|
||||
)
|
||||
get_image_reconciliation_service(app).start_background()
|
||||
|
||||
for module in MODULES:
|
||||
log.debug(f"Loading module {module.__name__}")
|
||||
@@ -107,8 +97,10 @@ async def shutdown(app: FastAPI) -> None:
|
||||
Tasks to be performed when the server is exiting.
|
||||
"""
|
||||
|
||||
if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled():
|
||||
auto_discover_images_task_handle.cancel()
|
||||
service = getattr(app.state, "image_reconciliation", None)
|
||||
if service is not None:
|
||||
await service.close()
|
||||
del app.state.image_reconciliation
|
||||
await HTTPClient.close_session()
|
||||
await MarkerManager.instance().stop()
|
||||
# Kill resident sharkd sessions (marker replay) and drop their /tmp
|
||||
|
||||
@@ -21,7 +21,7 @@ from .users import User, UserGroup
|
||||
from .roles import Role
|
||||
from .privileges import Privilege
|
||||
from .computes import Compute
|
||||
from .images import Image
|
||||
from .images import Image, ImageSyncJob
|
||||
from .pools import Resource, ResourcePool
|
||||
from .llm_model_configs import LLMModelConfig
|
||||
from .api_keys import ApiKey
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from sqlalchemy import Table, Column, String, ForeignKey, BigInteger, Integer
|
||||
from sqlalchemy import Table, Column, String, ForeignKey, BigInteger, Integer, DateTime, JSON, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .base import Base, BaseTable, GUID
|
||||
@@ -39,4 +39,21 @@ class Image(BaseTable):
|
||||
image_size = Column(BigInteger)
|
||||
checksum = Column(String, index=True)
|
||||
checksum_algorithm = Column(String)
|
||||
availability = Column(String, nullable=False, default="unknown", server_default="unknown")
|
||||
file_fingerprint = Column(String)
|
||||
last_seen_at = Column(DateTime)
|
||||
last_verified_at = Column(DateTime)
|
||||
last_error = Column(String)
|
||||
templates = relationship("Template", secondary=image_template_map, back_populates="images")
|
||||
|
||||
|
||||
class ImageSyncJob(BaseTable):
|
||||
__tablename__ = "image_sync_jobs"
|
||||
|
||||
job_id = Column(String, primary_key=True)
|
||||
status = Column(String, nullable=False)
|
||||
dry_run = Column(Boolean, nullable=False)
|
||||
force_checksum = Column(Boolean, nullable=False)
|
||||
finished_at = Column(DateTime)
|
||||
counts = Column(JSON, nullable=False)
|
||||
errors = Column(JSON, nullable=False)
|
||||
@@ -16,11 +16,15 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from typing import Optional, List, cast
|
||||
from typing import Optional, List, Callable, cast
|
||||
from sqlalchemy import select, delete, update
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from gns3server.utils.image_inventory import fingerprint, image_lock, normalized_path
|
||||
|
||||
from .base import BaseRepository
|
||||
|
||||
@@ -36,19 +40,21 @@ class ImagesRepository(BaseRepository):
|
||||
|
||||
super().__init__(db_session)
|
||||
|
||||
async def get_image(self, image_path: str) -> Optional[models.Image]:
|
||||
async def get_image(self, image_path: str, *, refresh: bool = False) -> Optional[models.Image]:
|
||||
"""
|
||||
Get an image by its path.
|
||||
"""
|
||||
|
||||
image_dir, image_name = os.path.split(image_path)
|
||||
if image_dir:
|
||||
if os.path.isabs(image_path):
|
||||
query = select(models.Image).where(models.Image.path == image_path)
|
||||
elif image_dir:
|
||||
query = select(models.Image).where(
|
||||
models.Image.filename == image_name, models.Image.path.endswith(image_path)
|
||||
models.Image.filename == image_name, models.Image.path.endswith(os.sep + image_path, autoescape=True)
|
||||
)
|
||||
else:
|
||||
query = select(models.Image).where(models.Image.filename == image_name)
|
||||
result = await self._db_session.execute(query)
|
||||
result = await self._db_session.execute(query.execution_options(populate_existing=refresh))
|
||||
return result.scalars().one_or_none()
|
||||
|
||||
async def get_image_by_checksum(self, checksum: str, image_dir: Optional[str] = None) -> Optional[models.Image]:
|
||||
@@ -56,18 +62,29 @@ class ImagesRepository(BaseRepository):
|
||||
Get an image by its checksum.
|
||||
"""
|
||||
|
||||
if image_dir:
|
||||
query = select(models.Image).where(
|
||||
models.Image.checksum == checksum, models.Image.path.startswith(image_dir)
|
||||
)
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().one_or_none()
|
||||
else:
|
||||
query = select(models.Image).where(models.Image.checksum == checksum)
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
query = select(models.Image).where(models.Image.checksum == checksum).order_by(models.Image.image_id)
|
||||
result = await self._db_session.execute(query)
|
||||
for image in result.scalars().all():
|
||||
if image_dir and normalized_path(os.path.dirname(image.path)) != normalized_path(image_dir):
|
||||
continue
|
||||
if await self.is_usable(image):
|
||||
return image
|
||||
return None
|
||||
|
||||
async def get_images(self, image_type=None) -> List[models.Image]:
|
||||
async def is_usable(self, image: models.Image) -> bool:
|
||||
"""Validate a checksum candidate without trusting stale catalog/sidecar data."""
|
||||
from gns3server.utils.images import inspect_image_file, InvalidImageError
|
||||
|
||||
try:
|
||||
current = await asyncio.to_thread(fingerprint, image.path)
|
||||
if image.availability == "available" and image.file_fingerprint == current:
|
||||
return True
|
||||
info = await asyncio.to_thread(inspect_image_file, image.path, image.image_type, True)
|
||||
return info["checksum"] == image.checksum and info["image_size"] == image.image_size
|
||||
except (OSError, InvalidImageError):
|
||||
return False
|
||||
|
||||
async def get_images(self, image_type=None, availability=None) -> List[models.Image]:
|
||||
"""
|
||||
Get all images.
|
||||
"""
|
||||
@@ -76,6 +93,8 @@ class ImagesRepository(BaseRepository):
|
||||
query = select(models.Image).where(models.Image.image_type == image_type)
|
||||
else:
|
||||
query = select(models.Image)
|
||||
if availability:
|
||||
query = query.where(models.Image.availability == availability)
|
||||
result = await self._db_session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -89,7 +108,9 @@ class ImagesRepository(BaseRepository):
|
||||
result = await self._db_session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def add_image(self, image_name, image_type, image_size, path, checksum, checksum_algorithm) -> models.Image:
|
||||
async def add_image(
|
||||
self, image_name, image_type, image_size, path, checksum, checksum_algorithm, file_fingerprint=None
|
||||
) -> models.Image:
|
||||
"""
|
||||
Create a new image.
|
||||
"""
|
||||
@@ -102,10 +123,16 @@ class ImagesRepository(BaseRepository):
|
||||
path=path,
|
||||
checksum=checksum,
|
||||
checksum_algorithm=checksum_algorithm,
|
||||
file_fingerprint=file_fingerprint,
|
||||
availability="available" if file_fingerprint else "unknown",
|
||||
)
|
||||
|
||||
self._db_session.add(db_image)
|
||||
await self._db_session.commit()
|
||||
try:
|
||||
await self._db_session.commit()
|
||||
except Exception:
|
||||
await self._db_session.rollback()
|
||||
raise
|
||||
await self._db_session.refresh(db_image)
|
||||
return db_image
|
||||
|
||||
@@ -127,16 +154,91 @@ class ImagesRepository(BaseRepository):
|
||||
await self._db_session.refresh(image_db) # force refresh of updated_at value
|
||||
return image_db
|
||||
|
||||
async def save_verified_image(self, info: dict) -> Optional[models.Image]:
|
||||
"""Upsert an exact path, preserving template associations and the image ID.
|
||||
|
||||
Callers coordinate publication/inspection with image_lock(). Each commit
|
||||
is short, and a concurrent insert from a legacy caller is retried safely.
|
||||
"""
|
||||
values = dict(info)
|
||||
values["filename"] = values.pop("image_name")
|
||||
values.update(
|
||||
availability="available",
|
||||
last_error=None,
|
||||
last_seen_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
last_verified_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
)
|
||||
for attempt in range(2):
|
||||
try:
|
||||
image = (
|
||||
await self._db_session.execute(
|
||||
select(models.Image)
|
||||
.where(models.Image.path == info["path"])
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if image is None:
|
||||
# A legacy spelling (e.g. /images/QEMU/./disk.qcow2) can
|
||||
# refer to the same destination restored by an API upload.
|
||||
candidates = (
|
||||
(
|
||||
await self._db_session.execute(
|
||||
select(models.Image).where(models.Image.filename == values["filename"])
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
aliases = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if normalized_path(candidate.path) == normalized_path(info["path"])
|
||||
]
|
||||
if len(aliases) > 1:
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
|
||||
raise MultipleResultsFound("Ambiguous image path aliases; manual review required")
|
||||
if aliases:
|
||||
image = aliases[0]
|
||||
values["path"] = image.path
|
||||
if image is None:
|
||||
image = models.Image(**values)
|
||||
self._db_session.add(image)
|
||||
else:
|
||||
for key, value in values.items():
|
||||
setattr(image, key, value)
|
||||
await self._db_session.commit()
|
||||
await self._db_session.refresh(image)
|
||||
return image
|
||||
except IntegrityError:
|
||||
await self._db_session.rollback()
|
||||
if attempt:
|
||||
raise
|
||||
except Exception:
|
||||
await self._db_session.rollback()
|
||||
raise
|
||||
return None
|
||||
|
||||
async def delete_image_exact(self, image_id: int) -> bool:
|
||||
result = await self._db_session.execute(delete(models.Image).where(models.Image.image_id == image_id))
|
||||
await self._db_session.commit()
|
||||
return cast(CursorResult, result).rowcount > 0
|
||||
|
||||
async def delete_image(self, image_path: str) -> bool:
|
||||
"""
|
||||
Delete an image.
|
||||
"""
|
||||
|
||||
image_dir, image_name = os.path.split(image_path)
|
||||
if image_dir:
|
||||
if os.path.isabs(image_path):
|
||||
query = delete(models.Image).where(models.Image.path == image_path)
|
||||
elif image_dir:
|
||||
query = (
|
||||
delete(models.Image)
|
||||
.where(models.Image.filename == image_name, models.Image.path.endswith(image_path))
|
||||
.where(
|
||||
models.Image.filename == image_name,
|
||||
models.Image.path.endswith(os.sep + image_path, autoescape=True),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
else:
|
||||
@@ -145,25 +247,45 @@ class ImagesRepository(BaseRepository):
|
||||
await self._db_session.commit()
|
||||
return cast(CursorResult, result).rowcount > 0
|
||||
|
||||
async def prune_images(self, skip_images: Optional[list[str]] = None) -> int:
|
||||
async def prune_images(self, skip_images: Optional[list[str]] = None, is_in_use: Optional[Callable] = None) -> int:
|
||||
"""
|
||||
Prune images not attached to any template.
|
||||
"""
|
||||
|
||||
query = select(models.Image).filter(~models.Image.templates.any())
|
||||
result = await self._db_session.execute(query)
|
||||
images = result.scalars().all()
|
||||
# Snapshot scalar values; commits can expire ORM instances.
|
||||
images = [
|
||||
(image.image_id, image.filename, image.path, image.checksum, image.file_fingerprint)
|
||||
for image in result.scalars().all()
|
||||
]
|
||||
images_deleted = 0
|
||||
for image in images:
|
||||
if skip_images and image.filename in skip_images:
|
||||
log.debug(f"Skipping image '{image.path}' for pruning")
|
||||
errors = []
|
||||
for image_id, filename, path, checksum, file_fingerprint in images:
|
||||
if skip_images and filename in skip_images:
|
||||
continue
|
||||
try:
|
||||
log.debug(f"Deleting image '{image.path}'")
|
||||
os.remove(image.path)
|
||||
except OSError:
|
||||
log.warning(f"Could not delete image file {image.path}")
|
||||
if await self.delete_image(image.path):
|
||||
images_deleted += 1
|
||||
async with image_lock(path):
|
||||
current = await self.get_image(path, refresh=True)
|
||||
if current is None or (current.image_id, current.checksum, current.file_fingerprint) != (
|
||||
image_id,
|
||||
checksum,
|
||||
file_fingerprint,
|
||||
):
|
||||
continue # A concurrent request removed or replaced this image.
|
||||
if await self.get_image_templates(image_id) or (is_in_use and is_in_use(filename)):
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
errors.append(f"{path}: {e}")
|
||||
continue
|
||||
if await self.delete_image_exact(image_id):
|
||||
images_deleted += 1
|
||||
log.info(f"{images_deleted} image(s) have been deleted")
|
||||
if errors:
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
|
||||
raise ControllerError("Could not delete image files: " + "; ".join(errors))
|
||||
return images_deleted
|
||||
@@ -31,6 +31,8 @@ from .base import BaseRepository
|
||||
|
||||
import gns3server.db.models as models
|
||||
from gns3server import schemas
|
||||
from gns3server.utils.image_inventory import image_lock
|
||||
from gns3server.controller.controller_error import ControllerNotFoundError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -136,18 +138,22 @@ class TemplatesRepository(BaseRepository):
|
||||
await self._db_session.refresh(db_template)
|
||||
return db_template
|
||||
|
||||
async def get_image(self, image_path: str) -> Optional[models.Image]:
|
||||
async def get_image(self, image_path: str, *, include_unavailable: bool = False) -> Optional[models.Image]:
|
||||
"""
|
||||
Get an image by its path.
|
||||
"""
|
||||
|
||||
image_dir, image_name = os.path.split(image_path)
|
||||
if image_dir:
|
||||
if os.path.isabs(image_path):
|
||||
query = select(models.Image).where(models.Image.path == image_path)
|
||||
elif image_dir:
|
||||
query = select(models.Image).where(
|
||||
models.Image.filename == image_name, models.Image.path.endswith(image_path)
|
||||
models.Image.filename == image_name, models.Image.path.endswith(os.sep + image_path, autoescape=True)
|
||||
)
|
||||
else:
|
||||
query = select(models.Image).where(models.Image.filename == image_name)
|
||||
if not include_unavailable:
|
||||
query = query.where(models.Image.availability.in_(["unknown", "available"]))
|
||||
query = query.order_by(models.Image.image_id)
|
||||
result = await self._db_session.execute(query)
|
||||
images = result.scalars().all()
|
||||
@@ -164,20 +170,28 @@ class TemplatesRepository(BaseRepository):
|
||||
Add an image to template.
|
||||
"""
|
||||
|
||||
query = (
|
||||
select(models.Template)
|
||||
.options(selectinload(models.Template.images))
|
||||
.where(models.Template.template_id == template_id)
|
||||
)
|
||||
result = await self._db_session.execute(query)
|
||||
template_in_db = result.scalars().first()
|
||||
if not template_in_db:
|
||||
return None
|
||||
async with image_lock(image.path):
|
||||
exists = (
|
||||
await self._db_session.execute(
|
||||
select(models.Image.image_id).where(models.Image.image_id == image.image_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
raise ControllerNotFoundError(f"Image '{image.path}' was removed while creating the template")
|
||||
query = (
|
||||
select(models.Template)
|
||||
.options(selectinload(models.Template.images))
|
||||
.where(models.Template.template_id == template_id)
|
||||
)
|
||||
result = await self._db_session.execute(query)
|
||||
template_in_db = result.scalars().first()
|
||||
if not template_in_db:
|
||||
return None
|
||||
|
||||
template_in_db.images.append(image)
|
||||
await self._db_session.commit()
|
||||
await self._db_session.refresh(template_in_db)
|
||||
return template_in_db
|
||||
template_in_db.images.append(image)
|
||||
await self._db_session.commit()
|
||||
await self._db_session.refresh(template_in_db)
|
||||
return template_in_db
|
||||
|
||||
async def remove_image_from_template(self, template_id: UUID, image: models.Image) -> Union[None, models.Template]:
|
||||
"""
|
||||
|
||||
+29
-143
@@ -15,8 +15,6 @@
|
||||
# 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 time
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -31,13 +29,10 @@ from alembic import command, config
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.util.exc import CommandError
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEvent, PatternMatchingEventHandler
|
||||
|
||||
from gns3server.db.repositories.computes import ComputesRepository
|
||||
from gns3server.db.repositories.images import ImagesRepository
|
||||
from gns3server.utils.images import md5sum, discover_images, read_image_info, InvalidImageError
|
||||
from gns3server.utils.asyncio import wait_run_in_executor
|
||||
from gns3server.utils.images import read_image_info
|
||||
from gns3server import schemas
|
||||
|
||||
from .models import Base
|
||||
@@ -142,7 +137,18 @@ async def connect_to_db(app: FastAPI) -> None:
|
||||
log.info("Created new database and stamped to head revision")
|
||||
elif db_state == "new_with_llm_configs":
|
||||
# Database already has llm_model_configs table (from Base.metadata.create_all)
|
||||
# Just stamp the version
|
||||
# Older unversioned metadata databases lack inventory fields.
|
||||
# Ensure the additive schema before stamping the current head.
|
||||
def upgrade_inventory(connection):
|
||||
from alembic.operations import Operations
|
||||
from gns3server.db_migrations.versions.d9e8a2b7c401_image_inventory_reconciliation import (
|
||||
upgrade,
|
||||
)
|
||||
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
upgrade()
|
||||
|
||||
await conn.run_sync(upgrade_inventory)
|
||||
await conn.run_sync(run_stamp, alembic_cfg)
|
||||
await conn.commit()
|
||||
log.info("Database has llm_model_configs table, stamped to head revision")
|
||||
@@ -186,145 +192,25 @@ async def get_computes(app: FastAPI) -> List[schemas.Compute]:
|
||||
return computes
|
||||
|
||||
|
||||
async def discover_images_on_filesystem(app: FastAPI) -> None:
|
||||
|
||||
async with AsyncSession(app.state._db_engine) as db_session:
|
||||
images_repository = ImagesRepository(db_session)
|
||||
db_images = await images_repository.get_images()
|
||||
existing_image_paths = []
|
||||
for db_image in db_images:
|
||||
try:
|
||||
image = schemas.Image.model_validate(db_image)
|
||||
existing_image_paths.append(image.path)
|
||||
except ValidationError as e:
|
||||
log.error(f"Could not load image '{db_image.filename}' from database: {e}")
|
||||
continue
|
||||
for image_type in ("qemu", "ios", "iou"):
|
||||
discovered_images = await discover_images(image_type, existing_image_paths)
|
||||
for image_info in discovered_images:
|
||||
log.info(f"Adding discovered image '{image_info['path']}' to the database")
|
||||
try:
|
||||
await images_repository.add_image(**image_info)
|
||||
except SQLAlchemyError as e:
|
||||
log.warning(f"Error while adding image '{image_info['path']}' to the database: {e}")
|
||||
|
||||
# monitor if images have been manually added
|
||||
asyncio.create_task(monitor_images_on_filesystem(app))
|
||||
|
||||
|
||||
async def update_disk_checksums(updated_disks: List[str]) -> None:
|
||||
"""
|
||||
Update the checksum of a list of disks in the database.
|
||||
|
||||
:param updated_disks: list of updated disks
|
||||
"""
|
||||
|
||||
"""Refresh complete metadata after a server-managed disk modification."""
|
||||
from gns3server.api.server import app
|
||||
from gns3server.utils.image_inventory import image_lock
|
||||
|
||||
async with AsyncSession(app.state._db_engine) as db_session:
|
||||
images_repository = ImagesRepository(db_session)
|
||||
for path in updated_disks:
|
||||
image = await images_repository.get_image(path)
|
||||
if image:
|
||||
log.info(f"Updating image '{path}' in the database")
|
||||
checksum = await wait_run_in_executor(md5sum, path, cache_to_md5file=False)
|
||||
if image.checksum != checksum:
|
||||
await images_repository.update_image(path, checksum, "md5")
|
||||
|
||||
|
||||
class EventHandler(PatternMatchingEventHandler):
|
||||
"""
|
||||
Watchdog event handler.
|
||||
"""
|
||||
|
||||
def __init__(self, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop, **kwargs):
|
||||
|
||||
self._loop = loop
|
||||
self._queue = queue
|
||||
|
||||
# ignore temporary files, md5sum files, hidden files and directories
|
||||
super().__init__(ignore_patterns=["*.tmp", "*.md5sum", ".*"], ignore_directories=True, **kwargs)
|
||||
|
||||
def on_closed(self, event: FileSystemEvent) -> None:
|
||||
# monitor for closed files (e.g. when a file has finished to be copied)
|
||||
if "/lib/" in event.src_path or "/lib64/" in event.src_path:
|
||||
return # ignore custom IOU libraries
|
||||
self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
|
||||
|
||||
|
||||
class EventIterator:
|
||||
"""
|
||||
Watchdog Event iterator.
|
||||
"""
|
||||
|
||||
def __init__(self, queue: asyncio.Queue):
|
||||
self.queue = queue
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
|
||||
item = await self.queue.get()
|
||||
if item is None:
|
||||
raise StopAsyncIteration
|
||||
return item
|
||||
|
||||
|
||||
async def monitor_images_on_filesystem(app: FastAPI):
|
||||
|
||||
def watchdog(
|
||||
path: str, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop, app: FastAPI, recursive: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Thread to monitor a directory for new images.
|
||||
"""
|
||||
|
||||
handler = EventHandler(queue, loop)
|
||||
observer = Observer()
|
||||
observer.schedule(handler, str(path), recursive=recursive)
|
||||
observer.start()
|
||||
log.info(f"Monitoring for new images in '{path}'")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
# stop when the app is exiting
|
||||
if app.state.exiting:
|
||||
observer.stop()
|
||||
observer.join(10)
|
||||
log.info(f"Stopping monitoring for new images in '{path}'")
|
||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||
break
|
||||
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
server_config = Config.instance().settings.Server
|
||||
image_dir = os.path.expanduser(server_config.images_path)
|
||||
asyncio.get_event_loop().run_in_executor(None, watchdog, image_dir, queue, loop, app, True)
|
||||
|
||||
async for filesystem_event in EventIterator(queue):
|
||||
# read the file system event from the queue
|
||||
image_path = filesystem_event.src_path
|
||||
expected_image_type = None
|
||||
if "IOU" in image_path:
|
||||
expected_image_type = "iou"
|
||||
elif "QEMU" in image_path:
|
||||
expected_image_type = "qemu"
|
||||
elif "IOS" in image_path:
|
||||
expected_image_type = "ios"
|
||||
async with AsyncSession(app.state._db_engine) as db_session:
|
||||
images_repository = ImagesRepository(db_session)
|
||||
try:
|
||||
image = await read_image_info(image_path, expected_image_type)
|
||||
except InvalidImageError as e:
|
||||
log.warning(str(e))
|
||||
continue
|
||||
try:
|
||||
if await images_repository.get_image(image_path):
|
||||
continue
|
||||
await images_repository.add_image(**image)
|
||||
log.info(f"Discovered image '{image_path}' has been added to the database")
|
||||
except SQLAlchemyError as e:
|
||||
log.warning(f"Error while adding image '{image_path}' to the database: {e}")
|
||||
for path in updated_disks:
|
||||
async with image_lock(path):
|
||||
async with AsyncSession(app.state._db_engine, expire_on_commit=False) as db_session:
|
||||
repository = ImagesRepository(db_session)
|
||||
image = await repository.get_image(path)
|
||||
if image:
|
||||
info = await read_image_info(path, str(image.image_type), allow_raw_image=True)
|
||||
try:
|
||||
os.unlink(path + ".md5sum")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning("Could not invalidate checksum cache for '%s': %s", path, e)
|
||||
await repository.save_verified_image(info)
|
||||
|
||||
|
||||
async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Track image availability and persistent synchronization jobs.
|
||||
|
||||
Revision ID: d9e8a2b7c401
|
||||
Revises: c7e4a9f1d2b6
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "d9e8a2b7c401"
|
||||
down_revision = "c7e4a9f1d2b6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Startup also uses this for unversioned databases previously created from
|
||||
# metadata, which may already contain some/all of these additive fields.
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
existing = {column["name"] for column in inspector.get_columns("images")}
|
||||
for column in (
|
||||
sa.Column("availability", sa.String(), nullable=False, server_default="unknown"),
|
||||
sa.Column("file_fingerprint", sa.String()),
|
||||
sa.Column("last_seen_at", sa.DateTime()),
|
||||
sa.Column("last_verified_at", sa.DateTime()),
|
||||
sa.Column("last_error", sa.String()),
|
||||
):
|
||||
if column.name not in existing:
|
||||
op.add_column("images", column)
|
||||
if inspector.has_table("image_sync_jobs"):
|
||||
return
|
||||
op.create_table(
|
||||
"image_sync_jobs",
|
||||
sa.Column("job_id", sa.String(), primary_key=True),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("dry_run", sa.Boolean(), nullable=False),
|
||||
sa.Column("force_checksum", sa.Boolean(), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime()),
|
||||
sa.Column("counts", sa.JSON(), nullable=False),
|
||||
sa.Column("errors", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("image_sync_jobs")
|
||||
# Do not rebuild images: dropping the old table during a SQLite batch
|
||||
# rebuild would cascade-delete image_template_map entries with FK enabled.
|
||||
for name in ("last_error", "last_verified_at", "last_seen_at", "file_fingerprint", "availability"):
|
||||
op.drop_column("images", name)
|
||||
@@ -41,7 +41,7 @@ from .controller.computes import (
|
||||
Compute,
|
||||
)
|
||||
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
|
||||
from .controller.images import Image, ImageType
|
||||
from .controller.images import Image, ImageType, ImageSyncRequest, ImageSyncJob
|
||||
from .controller.appliances import ApplianceVersion, ApplianceVersionV8, Appliance
|
||||
from .controller.drawings import Drawing
|
||||
from .controller.gns3vm import GNS3VM
|
||||
|
||||
@@ -184,6 +184,7 @@ class ServerSettings(BaseModel):
|
||||
)
|
||||
allow_raw_images: bool = Field(True, description="Allow raw images to be uploaded to the server")
|
||||
auto_discover_images: bool = Field(True, description="Automatically discover images in the images directory")
|
||||
image_sync_interval: int = Field(900, ge=10, description="Seconds between automatic image inventory scans")
|
||||
report_errors: bool = Field(True, description="Automatically send crash reports to the GNS3 team")
|
||||
additional_images_paths: List[str] = Field(
|
||||
default_factory=list,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
from pydantic import ConfigDict, BaseModel, Field
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal
|
||||
|
||||
from .base import DateTimeModelMixin
|
||||
|
||||
@@ -37,7 +39,28 @@ class ImageBase(BaseModel):
|
||||
image_size: int = Field(..., description="Image size in bytes")
|
||||
checksum: str = Field(..., description="Checksum value")
|
||||
checksum_algorithm: str = Field(..., description="Checksum algorithm")
|
||||
availability: Literal["unknown", "available", "missing", "unavailable", "invalid"] = "unknown"
|
||||
last_seen_at: Optional[datetime] = None
|
||||
last_verified_at: Optional[datetime] = None
|
||||
last_error: Optional[str] = None
|
||||
|
||||
|
||||
class Image(DateTimeModelMixin, ImageBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ImageSyncRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
dry_run: bool = False
|
||||
force_checksum: bool = False
|
||||
|
||||
|
||||
class ImageSyncJob(DateTimeModelMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
job_id: str
|
||||
status: Literal["queued", "running", "completed", "partial", "failed", "cancelled", "interrupted"]
|
||||
dry_run: bool
|
||||
force_checksum: bool
|
||||
finished_at: Optional[datetime] = None
|
||||
counts: dict[str, int]
|
||||
errors: list[dict[str, str]]
|
||||
@@ -120,6 +120,7 @@ class ServerSettingsUpdate(BaseModel):
|
||||
default_symbol_theme: Optional[BuiltinSymbolTheme] = None
|
||||
allow_raw_images: Optional[bool] = None
|
||||
auto_discover_images: Optional[bool] = None
|
||||
image_sync_interval: Optional[int] = Field(None, ge=10)
|
||||
report_errors: Optional[bool] = None
|
||||
additional_images_paths: Optional[List[str]] = None
|
||||
console_start_port_range: Optional[int] = Field(None, gt=0, le=65535)
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Recoverable controller image inventory, shared by manual and automatic sync."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.db.models import Image, ImageSyncJob
|
||||
from gns3server.db.repositories.images import ImagesRepository
|
||||
from gns3server.utils.image_inventory import (
|
||||
ImageLock,
|
||||
ImageLockBusy,
|
||||
contained_path,
|
||||
fingerprint,
|
||||
image_lock,
|
||||
normalized_path,
|
||||
)
|
||||
from gns3server.utils.images import inspect_image_file, InvalidImageError, ImageChangedError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
COUNTERS = (
|
||||
"scanned",
|
||||
"added",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"missing",
|
||||
"unavailable",
|
||||
"invalid",
|
||||
"deferred",
|
||||
"out_of_scope",
|
||||
"errors",
|
||||
"bytes_hashed",
|
||||
)
|
||||
RAW_EXTENSIONS = {".raw", ".img", ".iso", ".fd", ".vhd", ".vdi", ".bin"}
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def ignored(name):
|
||||
return name.startswith(".") or name.endswith((".tmp", ".md5sum"))
|
||||
|
||||
|
||||
def configured_roots():
|
||||
settings = Config.instance().settings.Server
|
||||
roots = sorted({normalized_path(p) for p in [settings.images_path, *settings.additional_images_paths]})
|
||||
# Keep parent roots only, avoiding duplicate scans and conflicting absence passes.
|
||||
return [root for root in roots if not any(root != other and contained_path(root, other) for other in roots)]
|
||||
|
||||
|
||||
def root_identity(root):
|
||||
info = os.stat(root)
|
||||
# Opening the directory tests readability, not just existence.
|
||||
with os.scandir(root):
|
||||
pass
|
||||
return info.st_dev, info.st_ino, os.path.realpath(root)
|
||||
|
||||
|
||||
def enumerate_root(root):
|
||||
"""Observe a root without creating directories or silently dropping failures."""
|
||||
files, errors = {}, []
|
||||
try:
|
||||
root_stat = os.stat(root)
|
||||
if not stat.S_ISDIR(root_stat.st_mode):
|
||||
raise NotADirectoryError(root)
|
||||
for directory, dirs, names in os.walk(
|
||||
root,
|
||||
onerror=lambda e: errors.append({"path": str(e.filename or root), "reason": str(e)}),
|
||||
followlinks=False,
|
||||
):
|
||||
dirs[:] = [
|
||||
name
|
||||
for name in dirs
|
||||
if not ignored(name)
|
||||
and name not in ("lib", "lib64")
|
||||
and not os.path.islink(os.path.join(directory, name))
|
||||
]
|
||||
for name in names:
|
||||
if ignored(name):
|
||||
continue
|
||||
path = normalized_path(os.path.join(directory, name))
|
||||
try:
|
||||
files[path] = fingerprint(path)
|
||||
except OSError as e:
|
||||
errors.append({"path": path, "reason": str(e)})
|
||||
# A mount/root replacement during traversal makes the absence pass unsafe.
|
||||
after = os.stat(root)
|
||||
if (root_stat.st_dev, root_stat.st_ino) != (after.st_dev, after.st_ino):
|
||||
errors.append({"path": root, "reason": "Image root changed during scan"})
|
||||
except OSError as e:
|
||||
errors.append({"path": root, "reason": str(e)})
|
||||
return files, errors
|
||||
|
||||
|
||||
class InventoryEvents(FileSystemEventHandler):
|
||||
def __init__(self, loop, dirty):
|
||||
self.loop, self.dirty = loop, dirty
|
||||
self._pending = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _mark_dirty(self):
|
||||
with self._lock:
|
||||
self._pending = False
|
||||
self.dirty.set()
|
||||
|
||||
def on_any_event(self, event):
|
||||
if event.event_type not in ("created", "modified", "closed", "deleted", "moved"):
|
||||
return
|
||||
paths = [event.src_path, getattr(event, "dest_path", "")]
|
||||
if any(path and not ignored(os.path.basename(path)) for path in paths):
|
||||
# A single dirty bit coalesces events with bounded memory, including
|
||||
# directory renames and bursts. The next scan observes current state.
|
||||
with self._lock:
|
||||
if self._pending:
|
||||
return
|
||||
self._pending = True
|
||||
self.loop.call_soon_threadsafe(self._mark_dirty)
|
||||
|
||||
|
||||
class ImageReconciliationService:
|
||||
def __init__(self, engine, settle_seconds=1.0):
|
||||
self.engine = engine
|
||||
self.settle_seconds = settle_seconds
|
||||
self.task = None
|
||||
self.scheduler = None
|
||||
self.observer = None
|
||||
self.dirty = asyncio.Event()
|
||||
self.stopping = threading.Event()
|
||||
self._watched_roots = None
|
||||
self._catalog_paths = {}
|
||||
self._ambiguous_paths = set()
|
||||
|
||||
async def start(self, dry_run=False, force_checksum=False):
|
||||
if self.stopping.is_set():
|
||||
raise ImageLockBusy("Image synchronization is shutting down")
|
||||
lock = ImageLock("image-inventory", wait=False)
|
||||
await lock.__aenter__()
|
||||
try:
|
||||
async with AsyncSession(self.engine, expire_on_commit=False) as session:
|
||||
# The OS lock proves no previous worker using this catalog is
|
||||
# active. Recover jobs abandoned by a crash before admitting one.
|
||||
await session.execute(
|
||||
update(ImageSyncJob)
|
||||
.where(ImageSyncJob.status.in_(["queued", "running"]))
|
||||
.values(status="interrupted", finished_at=utcnow())
|
||||
)
|
||||
job = ImageSyncJob(
|
||||
job_id=str(uuid.uuid4()),
|
||||
status="queued",
|
||||
dry_run=dry_run,
|
||||
force_checksum=force_checksum,
|
||||
counts=dict.fromkeys(COUNTERS, 0),
|
||||
errors=[],
|
||||
)
|
||||
session.add(job)
|
||||
# Bound history; active jobs are never removed.
|
||||
old_jobs = (
|
||||
select(ImageSyncJob.job_id)
|
||||
.where(ImageSyncJob.status.notin_(["queued", "running"]))
|
||||
.order_by(ImageSyncJob.created_at.desc())
|
||||
.offset(99)
|
||||
)
|
||||
await session.execute(delete(ImageSyncJob).where(ImageSyncJob.job_id.in_(old_jobs)))
|
||||
await session.commit()
|
||||
await session.refresh(job)
|
||||
result = job.asdict()
|
||||
self.task = asyncio.create_task(self._run(result, lock), name="image-inventory-sync")
|
||||
self.task.add_done_callback(self._finished)
|
||||
return result
|
||||
except BaseException:
|
||||
await lock.__aexit__()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _finished(task):
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
log.error("Could not persist image synchronization outcome: %s", task.exception())
|
||||
|
||||
async def get_job(self, job_id, offset=0, limit=100):
|
||||
# A client can resume polling after a restart even with automatic scans
|
||||
# disabled. Do not leave an abandoned job looking permanently active.
|
||||
try:
|
||||
async with ImageLock("image-inventory", wait=False):
|
||||
async with AsyncSession(self.engine) as session:
|
||||
await session.execute(
|
||||
update(ImageSyncJob)
|
||||
.where(ImageSyncJob.status.in_(["queued", "running"]))
|
||||
.values(status="interrupted", finished_at=utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
except ImageLockBusy:
|
||||
pass
|
||||
async with AsyncSession(self.engine) as session:
|
||||
job = await session.get(ImageSyncJob, job_id)
|
||||
if job is None:
|
||||
return None
|
||||
result = job.asdict()
|
||||
result["errors"] = result["errors"][offset : offset + limit]
|
||||
return result
|
||||
|
||||
async def _persist(self, job):
|
||||
async with AsyncSession(self.engine) as session:
|
||||
await session.execute(
|
||||
update(ImageSyncJob)
|
||||
.where(ImageSyncJob.job_id == job["job_id"])
|
||||
.values(
|
||||
status=job["status"],
|
||||
counts=dict(job["counts"]),
|
||||
errors=list(job["errors"]),
|
||||
finished_at=job.get("finished_at"),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
def _error(self, job, path, reason):
|
||||
job["counts"]["errors"] += 1
|
||||
# The total count remains accurate even when detailed errors are capped.
|
||||
if len(job["errors"]) < 1000:
|
||||
job["errors"].append({"path": path, "reason": str(reason)})
|
||||
|
||||
async def _inspect(self, path, expected, allow_raw):
|
||||
worker = asyncio.create_task(asyncio.to_thread(inspect_image_file, path, expected, allow_raw, self.stopping))
|
||||
try:
|
||||
return await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
self.stopping.set()
|
||||
try:
|
||||
await worker
|
||||
except (OSError, InvalidImageError):
|
||||
pass
|
||||
raise
|
||||
|
||||
async def _state(self, row, availability, reason, dry_run):
|
||||
if row is None or dry_run:
|
||||
return
|
||||
async with AsyncSession(self.engine) as session:
|
||||
# Revision guard for legacy writers not yet taking the path lock.
|
||||
await session.execute(
|
||||
update(Image)
|
||||
.where(
|
||||
Image.image_id == row["image_id"],
|
||||
Image.path == row["path"],
|
||||
Image.file_fingerprint == row["file_fingerprint"],
|
||||
Image.checksum == row["checksum"],
|
||||
)
|
||||
.values(availability=availability, last_error=str(reason))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _row(self, path):
|
||||
async with AsyncSession(self.engine) as session:
|
||||
stored = self._catalog_paths.get(normalized_path(path), path)
|
||||
row = (await session.execute(select(Image).where(Image.path.in_([stored, path])))).scalar_one_or_none()
|
||||
return row.asdict() if row else None
|
||||
|
||||
async def _file(self, path, observed, job, root): # noqa: C901 - keep per-file outcomes under one path lock
|
||||
if path in self._ambiguous_paths:
|
||||
job["counts"]["deferred"] += 1
|
||||
return
|
||||
async with image_lock(path):
|
||||
row = await self._row(path)
|
||||
counts = job["counts"]
|
||||
counts["scanned"] += 1
|
||||
try:
|
||||
if not contained_path(os.path.realpath(path), root):
|
||||
raise OSError("Image path resolves outside its configured root")
|
||||
current = await asyncio.to_thread(fingerprint, path)
|
||||
if current != observed:
|
||||
raise ImageChangedError("File changed during settling interval")
|
||||
if (
|
||||
row
|
||||
and row["availability"] == "available"
|
||||
and row["file_fingerprint"] == current
|
||||
and not job["force_checksum"]
|
||||
):
|
||||
counts["unchanged"] += 1
|
||||
return
|
||||
expected = row["image_type"] if row else None
|
||||
main_root = normalized_path(Config.instance().settings.Server.images_path)
|
||||
if not expected and contained_path(path, main_root):
|
||||
component = os.path.relpath(path, main_root).split(os.sep)[0]
|
||||
expected = {"QEMU": "qemu", "IOS": "ios", "IOU": "iou"}.get(component)
|
||||
allow_raw = Config.instance().settings.Server.allow_raw_images and (
|
||||
expected == "qemu"
|
||||
or (expected in (None, "qemu") and os.path.splitext(path)[1].lower() in RAW_EXTENSIONS)
|
||||
)
|
||||
info = await self._inspect(path, expected, allow_raw)
|
||||
if row:
|
||||
# Preserve legacy path spelling as well as image identity.
|
||||
info["path"] = row["path"]
|
||||
counts["bytes_hashed"] += info["image_size"]
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError
|
||||
if await asyncio.to_thread(fingerprint, path) != info["file_fingerprint"]:
|
||||
raise ImageChangedError("File changed before catalog update")
|
||||
if not job["dry_run"]:
|
||||
# Do not leave stale sidecars for legacy compute consumers.
|
||||
try:
|
||||
os.unlink(path + ".md5sum")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
self._error(job, path + ".md5sum", e)
|
||||
async with AsyncSession(self.engine, expire_on_commit=False) as session:
|
||||
await ImagesRepository(session).save_verified_image(info)
|
||||
counts["updated" if row else "added"] += 1
|
||||
except ImageChangedError as e:
|
||||
counts["deferred"] += 1
|
||||
await self._state(row, "unavailable", e, job["dry_run"])
|
||||
except InvalidImageError as e:
|
||||
counts["invalid"] += 1
|
||||
await self._state(row, "invalid", e, job["dry_run"])
|
||||
self._error(job, path, e)
|
||||
except OSError as e:
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError from e
|
||||
counts["unavailable"] += 1
|
||||
await self._state(row, "unavailable", e, job["dry_run"])
|
||||
self._error(job, path, e)
|
||||
|
||||
async def _run(self, job, lock): # noqa: C901 - one job owns root health, progress and lock lifetime
|
||||
job["status"] = "running"
|
||||
try:
|
||||
await self._persist(job)
|
||||
async with AsyncSession(self.engine) as session:
|
||||
paths = (await session.execute(select(Image.path))).scalars().all()
|
||||
self._catalog_paths = {}
|
||||
self._ambiguous_paths = set()
|
||||
roots = configured_roots()
|
||||
for path in paths:
|
||||
normalized = normalized_path(path)
|
||||
if not any(contained_path(path, root) for root in roots):
|
||||
job["counts"]["out_of_scope"] += 1
|
||||
self._error(job, path, "Catalog path is outside the configured image directories; record retained")
|
||||
if normalized in self._catalog_paths:
|
||||
self._ambiguous_paths.add(normalized)
|
||||
self._error(job, path, "Multiple catalog paths resolve to the same image; manual review required")
|
||||
self._catalog_paths[normalized] = path
|
||||
for root in roots:
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError
|
||||
resolved_root = os.path.realpath(root)
|
||||
try:
|
||||
identity = await asyncio.to_thread(root_identity, root)
|
||||
except OSError:
|
||||
identity = None
|
||||
files, errors = await asyncio.to_thread(enumerate_root, root)
|
||||
if resolved_root != os.path.realpath(root):
|
||||
errors.append({"path": root, "reason": "Image root target changed during scan"})
|
||||
for error in errors:
|
||||
self._error(job, error["path"], error["reason"])
|
||||
# One settling wait per root, not one per large image collection.
|
||||
if files and self.settle_seconds:
|
||||
await asyncio.sleep(self.settle_seconds)
|
||||
for path, observed in files.items():
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError
|
||||
try:
|
||||
await self._file(path, observed, job, resolved_root)
|
||||
except Exception as e:
|
||||
self._error(job, path, e)
|
||||
log.warning("Could not reconcile image %s: %s", path, e)
|
||||
if job["counts"]["scanned"] % 25 == 0:
|
||||
await self._persist(job)
|
||||
try:
|
||||
if identity != await asyncio.to_thread(root_identity, root):
|
||||
raise OSError("Image root changed during reconciliation")
|
||||
except OSError as e:
|
||||
errors.append({"path": root, "reason": str(e)})
|
||||
self._error(job, root, e)
|
||||
async with AsyncSession(self.engine) as session:
|
||||
rows = [image.asdict() for image in (await session.execute(select(Image))).scalars()]
|
||||
for row in rows:
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError
|
||||
path = row["path"]
|
||||
if (
|
||||
not contained_path(path, root)
|
||||
or normalized_path(path) in files
|
||||
or normalized_path(path) in self._ambiguous_paths
|
||||
):
|
||||
continue
|
||||
async with image_lock(path):
|
||||
row = await self._row(path)
|
||||
if row is None:
|
||||
continue
|
||||
if errors:
|
||||
job["counts"]["unavailable"] += 1
|
||||
await self._state(
|
||||
row, "unavailable", "Image root was not completely scanned", job["dry_run"]
|
||||
)
|
||||
continue
|
||||
try:
|
||||
if not contained_path(os.path.realpath(path), resolved_root):
|
||||
raise OSError("Image path resolves outside its configured root")
|
||||
await asyncio.to_thread(fingerprint, path)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
if identity != await asyncio.to_thread(root_identity, root):
|
||||
raise OSError("Image root changed during reconciliation")
|
||||
except OSError as e:
|
||||
job["counts"]["unavailable"] += 1
|
||||
await self._state(row, "unavailable", e, job["dry_run"])
|
||||
self._error(job, root, e)
|
||||
else:
|
||||
job["counts"]["missing"] += 1
|
||||
await self._state(row, "missing", "Image file is missing", job["dry_run"])
|
||||
except OSError as e:
|
||||
job["counts"]["unavailable"] += 1
|
||||
await self._state(row, "unavailable", e, job["dry_run"])
|
||||
self._error(job, path, e)
|
||||
if self.stopping.is_set():
|
||||
raise asyncio.CancelledError
|
||||
job["status"] = "partial" if job["counts"]["errors"] or job["counts"]["deferred"] else "completed"
|
||||
except asyncio.CancelledError:
|
||||
job["status"] = "cancelled"
|
||||
except Exception as e:
|
||||
job["status"] = "failed"
|
||||
self._error(job, "", e)
|
||||
log.exception("Image reconciliation failed")
|
||||
finally:
|
||||
job["finished_at"] = utcnow()
|
||||
try:
|
||||
await self._persist(job)
|
||||
finally:
|
||||
await lock.__aexit__()
|
||||
|
||||
async def _watch(self):
|
||||
roots = configured_roots() if Config.instance().settings.Server.auto_discover_images else []
|
||||
# Include availability in the signature so newly mounted roots get watched.
|
||||
roots = [root for root in roots if os.path.isdir(root)]
|
||||
if roots == self._watched_roots:
|
||||
return
|
||||
if self.observer:
|
||||
self.observer.stop()
|
||||
await asyncio.to_thread(self.observer.join)
|
||||
self.observer = None
|
||||
self._watched_roots = roots
|
||||
if roots:
|
||||
observer = Observer()
|
||||
handler = InventoryEvents(asyncio.get_running_loop(), self.dirty)
|
||||
try:
|
||||
for root in roots:
|
||||
observer.schedule(handler, root, recursive=True)
|
||||
observer.start()
|
||||
self.observer = observer
|
||||
except OSError:
|
||||
observer.stop()
|
||||
if observer.is_alive():
|
||||
await asyncio.to_thread(observer.join)
|
||||
self._watched_roots = None
|
||||
log.warning("Image watcher unavailable; periodic scanning remains active", exc_info=True)
|
||||
|
||||
async def _automatic(self):
|
||||
await asyncio.sleep(5)
|
||||
while not self.stopping.is_set():
|
||||
try:
|
||||
await self._watch()
|
||||
self.dirty.clear()
|
||||
if Config.instance().settings.Server.auto_discover_images:
|
||||
try:
|
||||
await self.start()
|
||||
await asyncio.shield(self.task)
|
||||
except ImageLockBusy:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(self.dirty.wait(), Config.instance().settings.Server.image_sync_interval)
|
||||
await asyncio.sleep(1) # debounce bursts of file/directory events
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
log.exception("Automatic image synchronization failed; will retry")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
def start_background(self):
|
||||
self.scheduler = asyncio.create_task(self._automatic(), name="image-inventory-scheduler")
|
||||
|
||||
async def close(self):
|
||||
self.stopping.set()
|
||||
if self.scheduler:
|
||||
self.scheduler.cancel()
|
||||
await asyncio.gather(self.scheduler, return_exceptions=True)
|
||||
if self.observer:
|
||||
self.observer.stop()
|
||||
await asyncio.to_thread(self.observer.join)
|
||||
self.observer = None
|
||||
if self.task and not self.task.done():
|
||||
# Cooperative cancellation also covers a task not yet entered; its
|
||||
# finally block must run to release the already-acquired job lock.
|
||||
await asyncio.gather(self.task, return_exceptions=True)
|
||||
|
||||
|
||||
def get_image_reconciliation_service(app):
|
||||
service = getattr(app.state, "image_reconciliation", None)
|
||||
if service is None:
|
||||
service = ImageReconciliationService(app.state._db_engine)
|
||||
app.state.image_reconciliation = service
|
||||
return service
|
||||
@@ -279,7 +279,8 @@ class TemplatesService:
|
||||
|
||||
if not image_path:
|
||||
return
|
||||
image = await self._templates_repo.get_image(image_path)
|
||||
# Removing an association must also find missing/invalid image rows.
|
||||
image = await self._templates_repo.get_image(image_path, include_unavailable=True)
|
||||
if image is None:
|
||||
return
|
||||
await self._templates_repo.remove_image_from_template(template_id, image)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Filesystem primitives shared by image writers and inventory reconciliation."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
|
||||
from gns3server.config import Config
|
||||
|
||||
|
||||
def normalized_path(path):
|
||||
return os.path.normcase(os.path.abspath(os.path.expanduser(path)))
|
||||
|
||||
|
||||
def contained_path(path, root):
|
||||
try:
|
||||
return os.path.commonpath((normalized_path(path), normalized_path(root))) == normalized_path(root)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
info = os.stat(path, follow_symlinks=True)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise OSError(f"Not a regular image file: {path}")
|
||||
return stat_fingerprint(info)
|
||||
|
||||
|
||||
def stat_fingerprint(info):
|
||||
# Store as text: inode/device numbers need not fit a signed SQL BIGINT.
|
||||
return ":".join(
|
||||
str(value) for value in (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
||||
)
|
||||
|
||||
|
||||
class ImageLockBusy(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ImageLock:
|
||||
"""Advisory lock shared by controller processes using the same config directory.
|
||||
|
||||
Lock files are deliberately retained: unlinking them permits two processes to
|
||||
lock different inodes for the same name. OS locks are released on process exit.
|
||||
"""
|
||||
|
||||
def __init__(self, key, wait=True):
|
||||
self.key = key
|
||||
self.wait = wait
|
||||
self._file = None
|
||||
|
||||
async def __aenter__(self):
|
||||
directory = os.path.join(Config.instance().config_dir, ".image-locks")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
path = os.path.join(directory, hashlib.sha256(self.key.encode()).hexdigest() + ".lock")
|
||||
self._file = open(path, "a+b")
|
||||
if os.name == "nt" and os.fstat(self._file.fileno()).st_size == 0:
|
||||
self._file.write(b"\0")
|
||||
self._file.flush()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
self._file.seek(0)
|
||||
msvcrt.locking(self._file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return self
|
||||
except (BlockingIOError, PermissionError):
|
||||
if not self.wait:
|
||||
raise ImageLockBusy(self.key)
|
||||
await asyncio.sleep(0.05)
|
||||
except BaseException:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
raise
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
|
||||
|
||||
def image_lock(path):
|
||||
return ImageLock("image:" + normalized_path(os.path.realpath(path)))
|
||||
|
||||
|
||||
def publish_image(temporary, destination):
|
||||
"""Atomically publish a complete file without replacing an existing image.
|
||||
|
||||
Both paths must be on the same filesystem. Hard linking provides the atomic
|
||||
no-overwrite operation that os.replace()/shutil.move() cannot provide.
|
||||
"""
|
||||
os.link(temporary, destination)
|
||||
os.unlink(temporary)
|
||||
+96
-64
@@ -18,7 +18,10 @@ import os
|
||||
import hashlib
|
||||
import stat
|
||||
import aiofiles
|
||||
import shutil
|
||||
import asyncio
|
||||
import tempfile
|
||||
|
||||
from gns3server.utils.image_inventory import fingerprint, stat_fingerprint, image_lock, publish_image, contained_path
|
||||
|
||||
try:
|
||||
import importlib_resources
|
||||
@@ -126,33 +129,46 @@ def get_builtin_disks() -> List[str]:
|
||||
return builtin_disks
|
||||
|
||||
|
||||
async def read_image_info(path: str, expected_image_type: str | None = None) -> dict:
|
||||
def inspect_image_file(path, expected_image_type=None, allow_raw_image=False, stopped_event=None):
|
||||
"""Read a stable regular file once, never trusting checksum sidecars."""
|
||||
before = fingerprint(path)
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
||||
with os.fdopen(os.open(path, flags), "rb") as f:
|
||||
info = os.fstat(f.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or stat_fingerprint(info) != before:
|
||||
raise ImageChangedError(f"Image changed while opening: {path}")
|
||||
header = f.read(7)
|
||||
if len(header) < 7:
|
||||
raise InvalidImageError(f"Image '{path}' is too small to be valid")
|
||||
image_type = check_valid_image_header(path, header, allow_raw_image)
|
||||
if expected_image_type and image_type != expected_image_type:
|
||||
raise InvalidImageError(f"Detected image type for '{path}' is {image_type}, expected {expected_image_type}")
|
||||
digest = hashlib.md5(header)
|
||||
while True:
|
||||
if stopped_event is not None and stopped_event.is_set():
|
||||
raise InterruptedError("Image inspection cancelled")
|
||||
chunk = f.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
if stat_fingerprint(os.fstat(f.fileno())) != before or fingerprint(path) != before:
|
||||
raise ImageChangedError(f"Image changed while reading: {path}")
|
||||
return dict(
|
||||
image_name=os.path.basename(path),
|
||||
image_type=image_type,
|
||||
image_size=info.st_size,
|
||||
path=path,
|
||||
checksum=digest.hexdigest(),
|
||||
checksum_algorithm="md5",
|
||||
file_fingerprint=before,
|
||||
)
|
||||
|
||||
header_magic_len = 7
|
||||
|
||||
async def read_image_info(path: str, expected_image_type: str | None = None, allow_raw_image=False) -> dict:
|
||||
try:
|
||||
async with aiofiles.open(path, "rb") as f:
|
||||
image_header = await f.read(header_magic_len) # read the first 7 bytes of the file
|
||||
if len(image_header) >= header_magic_len:
|
||||
detected_image_type = check_valid_image_header(path, image_header)
|
||||
if expected_image_type and detected_image_type != expected_image_type:
|
||||
raise InvalidImageError(
|
||||
f"Detected image type for '{path}' is {detected_image_type}, "
|
||||
f"expected type is {expected_image_type}"
|
||||
)
|
||||
else:
|
||||
raise InvalidImageError(f"Image '{path}' is too small to be valid")
|
||||
return await asyncio.to_thread(inspect_image_file, path, expected_image_type, allow_raw_image)
|
||||
except OSError as e:
|
||||
raise InvalidImageError(f"Cannot read image '{path}': {e}")
|
||||
|
||||
image_info = {
|
||||
"image_name": os.path.basename(path),
|
||||
"image_type": detected_image_type,
|
||||
"image_size": os.stat(path).st_size,
|
||||
"path": path,
|
||||
"checksum": await wait_run_in_executor(md5sum, path, cache_to_md5file=False),
|
||||
"checksum_algorithm": "md5",
|
||||
}
|
||||
return image_info
|
||||
raise InvalidImageError(f"Cannot read image '{path}': {e}") from e
|
||||
|
||||
|
||||
async def discover_images(image_type: str, skip_image_paths: list | None = None) -> List[dict]:
|
||||
@@ -244,7 +260,7 @@ def images_directories(image_type, include_parent_directory=True):
|
||||
return [force_unix_path(p) for p in paths if os.path.exists(p)]
|
||||
|
||||
|
||||
def md5sum(path, working_dir=None, stopped_event=None, cache_to_md5file=True):
|
||||
def md5sum(path, working_dir=None, stopped_event=None, cache_to_md5file=True, use_cache=True):
|
||||
"""
|
||||
Return the md5sum of an image and cache it on disk
|
||||
|
||||
@@ -263,7 +279,7 @@ def md5sum(path, working_dir=None, stopped_event=None, cache_to_md5file=True):
|
||||
else:
|
||||
md5sum_file = path + ".md5sum"
|
||||
|
||||
if os.path.exists(md5sum_file):
|
||||
if use_cache and os.path.exists(md5sum_file):
|
||||
try:
|
||||
with open(md5sum_file) as f:
|
||||
md5 = f.read().strip()
|
||||
@@ -319,6 +335,10 @@ class InvalidImageError(Exception):
|
||||
return self._message
|
||||
|
||||
|
||||
class ImageChangedError(InvalidImageError):
|
||||
"""An observation must be retried because the file is still changing."""
|
||||
|
||||
|
||||
def check_valid_image_header(path: str, data: bytes, allow_raw_image: bool = False) -> str:
|
||||
|
||||
if data[:7] == b"\x7fELF\x01\x02\x01":
|
||||
@@ -347,54 +367,66 @@ async def write_image(
|
||||
) -> models.Image:
|
||||
|
||||
image_dir, image_name = os.path.split(image_filename)
|
||||
# Store the file under its final name only when the upload is completed
|
||||
tmp_path = image_path + ".tmp"
|
||||
log.info(f"Writing image file to '{tmp_path}'")
|
||||
# HTTP chunk boundaries need not align with the seven-byte image header.
|
||||
iterator = stream.__aiter__()
|
||||
prefix = bytearray()
|
||||
async for chunk in iterator:
|
||||
prefix.extend(chunk)
|
||||
if len(prefix) >= 7:
|
||||
break
|
||||
if len(prefix) < 7:
|
||||
raise InvalidImageError("The image content is empty or too small to be valid")
|
||||
image_type = check_valid_image_header(image_path, bytes(prefix), allow_raw_image or not check_image_header)
|
||||
if not image_dir:
|
||||
image_path = os.path.abspath(os.path.join(default_images_directory(image_type), image_name))
|
||||
root = os.path.realpath(os.path.expanduser(Config.instance().settings.Server.images_path))
|
||||
if not contained_path(os.path.realpath(image_path), root):
|
||||
raise InvalidImageError(f"Image destination is outside the configured image directory: {image_path}")
|
||||
os.makedirs(os.path.dirname(image_path), exist_ok=True)
|
||||
descriptor, tmp_path = tempfile.mkstemp(prefix=".gns3-upload-", suffix=".tmp", dir=os.path.dirname(image_path))
|
||||
os.close(descriptor)
|
||||
checksum = hashlib.md5()
|
||||
header_magic_len = 7
|
||||
image_type = None
|
||||
image_size = 0
|
||||
try:
|
||||
async with aiofiles.open(tmp_path, "wb") as f:
|
||||
async for chunk in stream:
|
||||
if check_image_header and len(chunk) >= header_magic_len:
|
||||
check_image_header = False
|
||||
image_type = check_valid_image_header(image_path, chunk, allow_raw_image)
|
||||
await f.write(prefix)
|
||||
checksum.update(prefix)
|
||||
async for chunk in iterator:
|
||||
await f.write(chunk)
|
||||
checksum.update(chunk)
|
||||
|
||||
image_size = os.path.getsize(tmp_path)
|
||||
if not image_size or image_size < header_magic_len:
|
||||
raise InvalidImageError("The image content is empty or too small to be valid")
|
||||
|
||||
if not image_dir:
|
||||
directory = default_images_directory(image_type)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
image_path = os.path.abspath(os.path.join(directory, image_filename))
|
||||
|
||||
if os.path.exists(image_path):
|
||||
raise InvalidImageError(
|
||||
f"File '{image_path}' already exists, please choose a different name or remove the existing image"
|
||||
async with image_lock(image_path):
|
||||
if os.path.lexists(image_path):
|
||||
raise InvalidImageError(
|
||||
f"File '{image_path}' already exists, please choose a different name or remove the existing image"
|
||||
)
|
||||
checksum_str: str = checksum.hexdigest()
|
||||
duplicate_image = await images_repo.get_image_by_checksum(checksum_str, os.path.dirname(image_path))
|
||||
if duplicate_image:
|
||||
raise InvalidImageError(
|
||||
f"Image '{duplicate_image.filename}' with the same checksum "
|
||||
f"already exists in '{os.path.dirname(image_path)}'"
|
||||
)
|
||||
os.chmod(tmp_path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
||||
publish_image(tmp_path, image_path)
|
||||
# Complete files survive a database failure so the next scan can
|
||||
# recover them. Never compensate by unlinking a published image.
|
||||
image = await images_repo.save_verified_image(
|
||||
dict(
|
||||
image_name=image_name,
|
||||
image_type=image_type,
|
||||
image_size=image_size,
|
||||
path=image_path,
|
||||
checksum=checksum_str,
|
||||
checksum_algorithm="md5",
|
||||
file_fingerprint=fingerprint(image_path),
|
||||
)
|
||||
)
|
||||
|
||||
checksum_hex = checksum.hexdigest()
|
||||
image_dir = os.path.dirname(image_path)
|
||||
duplicate_image = await images_repo.get_image_by_checksum(checksum_hex, image_dir)
|
||||
if duplicate_image:
|
||||
raise InvalidImageError(
|
||||
f"Image '{duplicate_image.filename}' with the same checksum already exists in '{image_dir}'"
|
||||
)
|
||||
|
||||
shutil.move(tmp_path, image_path)
|
||||
os.chmod(image_path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
||||
if image is None:
|
||||
raise InvalidImageError(f"Failed to save image '{image_name}' to database")
|
||||
return image
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
log.warning(f"Could not remove '{tmp_path}'")
|
||||
|
||||
return await images_repo.add_image(
|
||||
image_name, image_type, image_size, image_path, checksum_hex, checksum_algorithm="md5"
|
||||
)
|
||||
Reference in new issue
Block a user