Merge pull request #2903 from cristian-ciobanu/image-manager-out-of-sync

fix(image-manager): Reconcile the image inventory with filesystem changes
This commit is contained in:
Jeremy Grossmann 2026-10-01 15:42:14 +02:00 committed by GitHub
commit 7be135f0ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2024 additions and 332 deletions

View File

@ -59,6 +59,16 @@ docs/
## Features
### Image inventory synchronization
Image Manager's **Sync Images** action reconciles the catalog with the configured
image directories. Automatic synchronization uses filesystem events and periodic
scans (`auto_discover_images`, `image_sync_interval`, default 900 seconds).
New and changed images are checksummed; missing records retain their IDs and
template associations and become available again when restored at the same path.
The `/v3/images/sync` API supports dry-run and forced checksum verification.
Database migration `d9e8a2b7c401` is required when upgrading an existing catalog.
Atomic image publication requires filesystem hard-link support.
### Controller + Compute Setup (`features/compute-controller-setup.md`)
Architecture and minimum configuration for setting up GNS3 Controller with remote Compute nodes. Covers compute node config, controller registration, and multi-compute deployment.

View File

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

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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]:
"""

View File

@ -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(object):
"""
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]:

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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]]

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"
)

View File

@ -277,7 +277,7 @@ extend-immutable-calls = [
"tests/test_utils.py" = ["F403", "F405"]
"tests/utils/test_asyncio.py" = ["B017"]
"tests/utils/test_packet_filter_validation.py" = ["W292"]
"tests/services/test_image_reconciliation.py" = ["S324"]
[tool.mypy]
files = ["gns3server"]

View File

@ -0,0 +1,138 @@
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from gns3server.db.repositories.images import ImagesRepository
from gns3server.services.image_reconciliation import ImageReconciliationService
from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
pytestmark = pytest.mark.asyncio
QCOW = b"QFI\xfb\x00\x00\x00"
@pytest_asyncio.fixture
async def sync_service(app, db_engine, db_session, monkeypatch):
service = ImageReconciliationService(db_engine, settle_seconds=0)
monkeypatch.setattr(app.state, "image_reconciliation", service, raising=False)
try:
yield service
finally:
await service.close()
class TestImageSyncRoutes:
async def test_manual_sync_with_auto_discovery_disabled(self, app, client, sync_service, config, images_dir):
assert not config.settings.Server.auto_discover_images
path = Path(images_dir) / "QEMU" / "manual.qcow2"
path.write_bytes(QCOW)
response = await client.post("/v3/images/sync", json={})
assert response.status_code == 202
assert response.json()["job_id"] in response.headers["Location"]
await sync_service.task
job = await client.get(response.headers["Location"])
assert job.status_code == 200
assert job.json()["status"] == "completed"
assert job.json()["counts"]["added"] == 1
listed = await client.get("/v3/images", params={"availability": "available"})
assert any(image["path"] == str(path) for image in listed.json())
path.unlink()
await client.post("/v3/images/sync", json={})
await sync_service.task
missing = await client.get("/v3/images", params={"availability": "missing"})
assert any(image["path"] == str(path) for image in missing.json())
async def test_dry_run_and_request_validation(self, client, sync_service, images_dir):
path = Path(images_dir) / "QEMU" / "preview.qcow2"
path.write_bytes(QCOW)
response = await client.post("/v3/images/sync", json={"dry_run": True, "force_checksum": True})
assert response.status_code == 202
await sync_service.task
result = await client.get(response.headers["Location"], params={"offset": 0, "limit": 1})
assert result.json()["counts"]["added"] == 1
listed = await client.get("/v3/images")
assert all(image["path"] != str(path) for image in listed.json())
assert (await client.post("/v3/images/sync", json={"path": "/etc"})).status_code == 422
assert (await client.get(response.headers["Location"], params={"limit": 1001})).status_code == 422
assert (await client.get("/v3/images/sync/jobs/does-not-exist")).status_code == 404
async def test_overlapping_sync_is_conflict(self, client, sync_service):
from gns3server.utils.image_inventory import ImageLock
async with ImageLock("image-inventory"):
response = await client.post("/v3/images/sync", json={})
assert response.status_code == 409
async def test_delete_and_prune_keep_rows_on_permission_error(self, client, db_session, images_dir):
response = await client.post("/v3/images/upload/protected.qcow2", content=QCOW)
assert response.status_code == 201
path = response.json()["path"]
with patch("gns3server.api.routes.controller.images.os.remove", side_effect=PermissionError("denied")):
response = await client.delete("/v3/images/protected.qcow2")
assert response.status_code == 409
response = await client.delete("/v3/images/prune")
assert response.status_code == 409
assert os.path.exists(path)
assert await ImagesRepository(db_session).get_image(path) is not None
os.unlink(path)
assert (await client.delete("/v3/images/protected.qcow2")).status_code == 204
async def test_create_does_not_overwrite_unindexed_file(self, client, images_dir):
path = Path(images_dir) / "QEMU" / "existing.qcow2"
path.write_bytes(QCOW + b"preserve me")
response = await client.post("/v3/images/qemu/existing.qcow2", json={"format": "qcow2", "size": 1})
assert response.status_code == 400
assert path.read_bytes() == QCOW + b"preserve me"
async def test_upload_rejects_path_prefix_sibling_and_symlink(self, client, images_dir, tmp_path):
sibling = images_dir + "-outside/escape.qcow2"
response = await client.post("/v3/images/upload/" + sibling, content=QCOW)
assert response.status_code == 403
assert not os.path.exists(sibling)
link = Path(images_dir) / "external"
link.symlink_to(tmp_path, target_is_directory=True)
response = await client.post("/v3/images/upload/external/escape.qcow2", content=QCOW)
assert response.status_code == 403
assert not (tmp_path / "escape.qcow2").exists()
async def test_sync_requires_allocate_privilege(self, client, sync_service, test_user):
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
with patch(
"gns3server.db.repositories.rbac.RbacRepository.check_user_has_privilege", new=AsyncMock(return_value=False)
):
response = await client.post("/v3/images/sync", json={}, headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 403
assert "Image.Allocate" in response.text
response = await client.get("/v3/images/sync/jobs/test", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 403
assert "Image.Audit" in response.text
response = await client.post("/v3/images/sync", json={}, headers={"Authorization": ""})
assert response.status_code == 401
async def test_template_can_detach_missing_image(self, app, client, sync_service, db_session):
uploaded = await client.post("/v3/images/upload/detach-missing.qcow2", content=QCOW)
assert uploaded.status_code == 201
image = uploaded.json()
image_id = (await ImagesRepository(db_session).get_image(image["path"])).image_id
created = await client.post(
"/v3/templates",
json={
"name": "Detach missing image",
"compute_id": "local",
"template_type": "qemu",
"hda_disk_image": image["filename"],
},
)
assert created.status_code == 201
template_id = created.json()["template_id"]
os.unlink(image["path"])
await client.post("/v3/images/sync", json={})
await sync_service.task
updated = await client.put(f"/v3/templates/{template_id}", json={"hda_disk_image": ""})
assert updated.status_code == 200
assert not await ImagesRepository(db_session).get_image_templates(image_id)
# Detaching a template does not silently remove the missing catalog row.
assert await ImagesRepository(db_session).get_image(image["path"]) is not None

View File

@ -110,15 +110,18 @@ def empty_image(tmpdir) -> str:
class TestImageRoutes:
async def test_create_image(self, app: FastAPI, client: AsyncClient, images_dir) -> None:
Qemu.instance().create_disk_image = AsyncioMagicMock()
async def create_disk(path, options):
with open(path, "wb") as f:
f.write(b"QFI\xfb\x00\x00\x00")
Qemu.instance().create_disk_image = AsyncioMagicMock(side_effect=create_disk)
path = os.path.join(os.path.join(images_dir, "QEMU", "new_image.qcow2"))
with open(path, "wb+") as f:
f.write(b"QFI\xfb\x00\x00\x00")
image_name = os.path.basename(path)
response = await client.post(
app.url_path_for("create_qemu_image", image_path=image_name), json={"format": "qcow2", "size": 30}
)
assert response.status_code == status.HTTP_201_CREATED
assert os.path.isfile(path)
@pytest.mark.parametrize(
"image_type, fixture_name, valid_request",

View File

@ -1,4 +1,5 @@
import pytest
import asyncio
import pytest_asyncio
import tempfile
import shutil
@ -101,12 +102,32 @@ async def base_client(app: FastAPI, db_session: AsyncSession) -> AsyncGenerator[
app.dependency_overrides[get_db_session] = _get_test_db
async with AsyncClient(
base_url="http://test-api",
headers={"Content-Type": "application/json"},
transport=ASGIWebSocketTransport(app=app),
) as async_client:
yield async_client
# AnyIO cancel scopes must be entered and exited by the same task. Recent
# pytest-asyncio versions finalize yield fixtures in a different task.
ready = asyncio.get_running_loop().create_future()
finished = asyncio.Event()
async def own_client():
try:
async with AsyncClient(
base_url="http://test-api",
headers={"Content-Type": "application/json"},
transport=ASGIWebSocketTransport(app=app),
) as async_client:
ready.set_result(async_client)
await finished.wait()
except BaseException as e:
if not ready.done():
ready.set_exception(e)
raise
owner = asyncio.create_task(own_client())
try:
yield await ready
finally:
finished.set()
await owner
app.dependency_overrides.pop(get_db_session, None)
@pytest_asyncio.fixture(loop_scope="class", scope="class")

View File

@ -0,0 +1,706 @@
import asyncio
import hashlib
import os
import sys
import threading
import subprocess
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
import pytest_asyncio
from sqlalchemy import select, text, event
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.exc import SQLAlchemyError
from watchdog.events import FileMovedEvent, FileDeletedEvent, FileModifiedEvent
from gns3server.db.models import Base, Image, ImageSyncJob, Template
from gns3server.db.models.images import image_template_map
from gns3server.db.repositories.images import ImagesRepository
from gns3server.services.image_reconciliation import ImageReconciliationService, InventoryEvents, enumerate_root
from gns3server.utils.image_inventory import ImageLockBusy, image_lock, fingerprint
from gns3server.utils.images import write_image, inspect_image_file, md5sum, InvalidImageError
pytestmark = pytest.mark.asyncio
QCOW = b"QFI\xfb\x00\x00\x00"
@pytest_asyncio.fixture
async def inventory(tmp_path, config):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'catalog.db'}")
@event.listens_for(engine.sync_engine, "connect")
def foreign_keys(connection, record):
connection.execute("PRAGMA foreign_keys=ON")
async with engine.connect() as conn:
with patch("gns3server.services.authentication.AuthService.hash_password", return_value="test"):
await conn.run_sync(Base.metadata.create_all)
await conn.commit()
service = ImageReconciliationService(engine, settle_seconds=0)
try:
yield service
finally:
await service.close()
await engine.dispose()
def image_file(config, name="QEMU/image.qcow2", data=QCOW):
path = Path(config.settings.Server.images_path) / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
async def scan(service, **kwargs):
job = await service.start(**kwargs)
await service.task
return await service.get_job(job["job_id"])
async def rows(service):
async with AsyncSession(service.engine) as session:
return [image.asdict() for image in (await session.execute(select(Image))).scalars()]
async def test_add_change_missing_restore_preserves_identity_and_template(inventory, config):
path = image_file(config)
assert (await scan(inventory))["counts"]["added"] == 1
original = (await rows(inventory))[0]
async with AsyncSession(inventory.engine) as session:
image = await session.get(Image, original["image_id"])
session.add(Template(name="Keep this reference", images=[image]))
await session.commit()
path.write_bytes(QCOW + b"changed")
assert (await scan(inventory))["counts"]["updated"] == 1
changed = (await rows(inventory))[0]
assert changed["image_id"] == original["image_id"]
assert changed["image_size"] == len(QCOW + b"changed")
assert changed["checksum"] == hashlib.md5(QCOW + b"changed", usedforsecurity=False).hexdigest()
path.unlink()
assert (await scan(inventory))["counts"]["missing"] == 1
assert (await rows(inventory))[0]["availability"] == "missing"
path.write_bytes(QCOW)
await scan(inventory)
restored = (await rows(inventory))[0]
assert restored["image_id"] == original["image_id"]
assert restored["availability"] == "available"
async with AsyncSession(inventory.engine) as session:
assert len((await session.execute(select(image_template_map))).all()) == 1
async def test_fingerprints_skip_hashing_and_force_bypasses_sidecars(inventory, config):
path = image_file(config)
await scan(inventory)
with patch(
"gns3server.services.image_reconciliation.inspect_image_file", side_effect=AssertionError("unnecessary hash")
):
result = await scan(inventory)
assert result["status"] == "completed"
assert result["counts"]["unchanged"] == 1
assert result["counts"]["bytes_hashed"] == 0
path.write_bytes(QCOW[:-1] + b"x") # same-size modification
sidecar = Path(str(path) + ".md5sum")
sidecar.write_text("0" * 32)
assert md5sum(str(path), cache_to_md5file=False) == "0" * 32
assert (
md5sum(str(path), cache_to_md5file=False, use_cache=False)
== hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
)
result = await scan(inventory, force_checksum=True)
assert result["counts"]["bytes_hashed"] == len(QCOW)
assert (await rows(inventory))[0]["checksum"] == hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
assert not sidecar.exists()
async def test_dry_run_does_not_write_catalog_or_sidecar(inventory, config):
path = image_file(config)
sidecar = Path(str(path) + ".md5sum")
sidecar.write_text("0" * 32)
result = await scan(inventory, dry_run=True, force_checksum=True)
assert result["counts"]["added"] == 1
assert await rows(inventory) == []
assert sidecar.read_text() == "0" * 32
await scan(inventory)
before = await rows(inventory)
path.unlink()
assert (await scan(inventory, dry_run=True))["counts"]["missing"] == 1
assert await rows(inventory) == before
@pytest.mark.parametrize("failure", ["missing_root", "partial_walk", "unreadable_file"])
async def test_failed_scopes_do_not_remove_rows(inventory, config, failure):
path = image_file(config)
await scan(inventory)
root = str(config.settings.Server.images_path)
if failure == "missing_root":
os.rename(root, root + "-offline")
result = await scan(inventory)
elif failure == "partial_walk":
with patch(
"gns3server.services.image_reconciliation.enumerate_root",
return_value=({}, [{"path": root, "reason": "Permission denied"}]),
):
result = await scan(inventory)
else:
with patch(
"gns3server.services.image_reconciliation.inspect_image_file", side_effect=PermissionError("denied")
):
result = await scan(inventory, force_checksum=True)
assert result["status"] == "partial"
assert result["counts"]["missing"] == 0
assert len(await rows(inventory)) == 1
assert (await rows(inventory))[0]["availability"] == "unavailable"
async def test_extra_nested_and_overlapping_roots(inventory, config, tmp_path):
extra = tmp_path / "extra"
extra.mkdir()
(extra / "image.qcow2").write_bytes(QCOW)
image_file(config, "QEMU/nested/image.qcow2")
image_file(config, "legacy.qcow2")
config.settings.Server.additional_images_paths = [str(extra), str(extra), config.settings.Server.images_path]
result = await scan(inventory)
assert result["counts"]["added"] == 3
assert len(await rows(inventory)) == 3
async def test_rename_retains_old_reference(inventory, config):
path = image_file(config)
await scan(inventory)
old = (await rows(inventory))[0]
path.rename(path.with_name("renamed.qcow2"))
result = await scan(inventory)
assert result["counts"]["added"] == result["counts"]["missing"] == 1
assert next(row for row in await rows(inventory) if row["image_id"] == old["image_id"])["availability"] == "missing"
async def test_symlink_images_are_imported_and_target_changes_detected(inventory, config):
target = image_file(config, ".storage/target.qcow2")
path = image_file(config)
path.unlink()
path.symlink_to(target)
assert fingerprint(path) == fingerprint(target)
result = await scan(inventory)
assert result["status"] == "completed", result
assert result["counts"]["added"] == 1
original = (await rows(inventory))[0]
assert original["path"] == str(path)
assert original["checksum"] == hashlib.md5(QCOW).hexdigest()
target.write_bytes(QCOW + b"changed")
result = await scan(inventory)
assert result["counts"]["updated"] == 1
updated = (await rows(inventory))[0]
assert updated["image_id"] == original["image_id"]
assert updated["checksum"] == hashlib.md5(target.read_bytes()).hexdigest()
assert path.is_symlink()
@pytest.mark.parametrize("target_kind", ["directory", "missing"])
async def test_fingerprint_rejects_symlinks_without_regular_file_targets(tmp_path, target_kind):
target = tmp_path / "target"
if target_kind == "directory":
target.mkdir()
link = tmp_path / "image.qcow2"
link.symlink_to(target, target_is_directory=target_kind == "directory")
with pytest.raises(OSError):
fingerprint(link)
async def test_external_symlinks_hidden_files_and_libraries_are_not_imported(inventory, config, tmp_path):
outside = tmp_path / "outside.qcow2"
outside.write_bytes(QCOW)
path = image_file(config)
path.unlink()
path.symlink_to(outside)
image_file(config, ".hidden/hidden.qcow2")
image_file(config, "IOU/lib/library.so")
image_file(config, "QEMU/upload.tmp")
result = await scan(inventory)
assert result["counts"]["added"] == 0
assert outside.read_bytes() == QCOW
@pytest.mark.parametrize(
"name,data,expected",
[
("QEMU/disk.raw", b"raw image bytes", "qemu"),
("IOS/ios.bin", b"\x7fELF\x01\x02\x01", "ios"),
("IOU/iou.bin", b"\x7fELF\x02\x01\x01", "iou"),
],
)
async def test_image_types_and_raw_policy(inventory, config, name, data, expected):
image_file(config, name, data)
result = await scan(inventory)
assert result["counts"]["added"] == 1
assert (await rows(inventory))[0]["image_type"] == expected
async def test_invalid_replacement_not_usable(inventory, config):
path = image_file(config)
await scan(inventory)
path.write_bytes(b"bad")
assert (await scan(inventory))["counts"]["invalid"] == 1
async with AsyncSession(inventory.engine) as session:
repo = ImagesRepository(session)
assert await repo.get_image_by_checksum(hashlib.md5(QCOW, usedforsecurity=False).hexdigest()) is None
assert (await rows(inventory))[0]["checksum"] == hashlib.md5(QCOW, usedforsecurity=False).hexdigest()
async def test_changed_during_inspection_is_deferred(inventory, config):
path = image_file(config)
original = inspect_image_file
def replace_after_hash(*args):
info = original(*args)
path.write_bytes(QCOW + b"still copying")
return info
with patch("gns3server.services.image_reconciliation.inspect_image_file", side_effect=replace_after_hash):
result = await scan(inventory)
assert result["counts"]["deferred"] == 1
assert await rows(inventory) == []
assert (await scan(inventory))["counts"]["added"] == 1
async def test_per_file_database_failure_does_not_poison_next_file(inventory, config):
image_file(config, "QEMU/first.qcow2")
image_file(config, "QEMU/second.qcow2")
original = ImagesRepository.save_verified_image
calls = 0
async def fail_first(repo, info):
nonlocal calls
calls += 1
if calls == 1:
# Real failed SQL statement in the file's session.
await repo._db_session.execute(text("INSERT INTO nonexistent_image_table VALUES (1)"))
return await original(repo, info)
with patch.object(ImagesRepository, "save_verified_image", fail_first):
result = await scan(inventory)
assert result["status"] == "partial"
assert result["counts"]["added"] == 1
assert (await scan(inventory))["counts"]["added"] == 1
assert len(await rows(inventory)) == 2
async def test_overlapping_jobs_rejected_and_interrupted_job_recovers(inventory, config):
path = image_file(config)
async with image_lock(str(path)):
job = await inventory.start()
other = ImageReconciliationService(inventory.engine, settle_seconds=0)
with pytest.raises(ImageLockBusy):
await other.start()
await inventory.task
async with AsyncSession(inventory.engine) as session:
stale = await session.get(ImageSyncJob, job["job_id"])
stale.status = "running"
await session.commit()
await scan(inventory)
assert (await inventory.get_job(job["job_id"]))["status"] == "interrupted"
async def test_close_immediately_releases_lock_and_finishes_job(inventory, config):
image_file(config)
job = await inventory.start()
await inventory.close()
assert (await inventory.get_job(job["job_id"]))["status"] == "cancelled"
other = ImageReconciliationService(inventory.engine, settle_seconds=0)
try:
assert (await scan(other))["status"] == "completed"
finally:
await other.close()
async def test_watcher_coalesces_move_delete_modify_events():
dirty = asyncio.Event()
handler = InventoryEvents(asyncio.get_running_loop(), dirty)
for fs_event in [
FileMovedEvent("/images/upload.tmp", "/images/new.qcow2"),
FileDeletedEvent("/images/deleted.qcow2"),
FileModifiedEvent("/images/changed.qcow2"),
]:
dirty.clear()
handler.dispatch(fs_event)
await asyncio.sleep(0)
assert dirty.is_set()
dirty.clear()
handler.dispatch(FileModifiedEvent("/images/new.qcow2.md5sum"))
await asyncio.sleep(0)
assert not dirty.is_set()
async def stream(data, chunk_size=1):
for offset in range(0, len(data), chunk_size):
yield data[offset : offset + chunk_size]
async def test_upload_fragmented_header_and_missing_row_reuse(inventory, config):
path = image_file(config)
await scan(inventory)
old_id = (await rows(inventory))[0]["image_id"]
path.unlink()
await scan(inventory)
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
image = await write_image("QEMU/image.qcow2", str(path), stream(QCOW), ImagesRepository(session))
assert image.image_id == old_id
assert image.availability == "available"
assert path.read_bytes() == QCOW
assert not list(path.parent.glob("*.tmp"))
async def test_upload_db_failure_is_recovered_by_scan(inventory, config):
path = Path(config.settings.Server.images_path) / "QEMU/orphan.qcow2"
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
repo = ImagesRepository(session)
with patch.object(repo, "save_verified_image", side_effect=SQLAlchemyError("commit failed")):
with pytest.raises(SQLAlchemyError):
await write_image("QEMU/orphan.qcow2", str(path), stream(QCOW), repo)
assert path.read_bytes() == QCOW
assert (await scan(inventory))["counts"]["added"] == 1
async def test_concurrent_uploads_never_overwrite(inventory, config):
path = Path(config.settings.Server.images_path) / "QEMU/same.qcow2"
async def upload(data):
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
return await write_image("QEMU/same.qcow2", str(path), stream(data), ImagesRepository(session))
result = await asyncio.gather(upload(QCOW), upload(QCOW + b"different"), return_exceptions=True)
assert sum(isinstance(item, InvalidImageError) for item in result) == 1
assert len(await rows(inventory)) == 1
assert (await rows(inventory))[0]["checksum"] == hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
async def test_checksum_lookup_ignores_missing_duplicate(inventory, config):
first = image_file(config, "QEMU/first.qcow2")
second = image_file(config, "QEMU/second.qcow2")
await scan(inventory)
first.unlink()
# No sync is needed to reject a stale candidate at the point of use.
async with AsyncSession(inventory.engine) as session:
image = await ImagesRepository(session).get_image_by_checksum(
hashlib.md5(QCOW, usedforsecurity=False).hexdigest(), str(second.parent)
)
assert image.path == str(second)
async def test_path_matching_does_not_interpret_sql_wildcards(inventory, config):
path = image_file(config, "QEMU/name_%/disk.qcow2")
image_file(config, "QEMU/name_AB/disk.qcow2")
await scan(inventory)
async with AsyncSession(inventory.engine) as session:
repo = ImagesRepository(session)
assert (await repo.get_image("name_%/disk.qcow2")).path == str(path)
assert await repo.get_image("me_%/disk.qcow2") is None
async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config):
from alembic import command
from alembic.config import Config
image_file(config)
await scan(inventory)
original = (await rows(inventory))[0]
async with AsyncSession(inventory.engine) as session:
image = await session.get(Image, original["image_id"])
session.add(Template(name="Migration reference", images=[image]))
await session.commit()
def migrate(connection):
cfg = Config()
cfg.set_main_option("script_location", "gns3server:db_migrations")
cfg.attributes["connection"] = connection
command.stamp(cfg, "d9e8a2b7c401")
command.downgrade(cfg, "c7e4a9f1d2b6")
assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1
assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"]
command.upgrade(cfg, "head")
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
async with inventory.engine.connect() as connection:
await connection.run_sync(migrate)
await connection.commit()
migrated = (await rows(inventory))[0]
assert migrated["availability"] == "unknown"
assert migrated["file_fingerprint"] is None
assert migrated["image_id"] == original["image_id"]
await scan(inventory)
assert (await rows(inventory))[0]["availability"] == "available"
@pytest.mark.skipif(sys.platform != "linux", reason="Native Linux watcher integration")
async def test_native_watcher_covers_extra_roots_and_stops(inventory, config, tmp_path):
extra = tmp_path / "extra-watch"
extra.mkdir()
config.settings.Server.additional_images_paths = [str(extra)]
config.settings.Server.auto_discover_images = True
await inventory._watch()
observer = inventory.observer
assert observer.is_alive()
inventory.dirty.clear()
temporary = extra / "incoming.tmp"
temporary.write_bytes(QCOW)
temporary.rename(extra / "finished.qcow2")
await asyncio.wait_for(inventory.dirty.wait(), 5)
assert (await scan(inventory))["counts"]["added"] == 1
inventory.dirty.clear()
(extra / "finished.qcow2").unlink()
await asyncio.wait_for(inventory.dirty.wait(), 5)
assert (await scan(inventory))["counts"]["missing"] == 1
await inventory.close()
assert not observer.is_alive()
@pytest.mark.skipif(os.name == "nt", reason="POSIX lock integration")
async def test_image_lock_excludes_another_process(config, tmp_path):
path = str(tmp_path / "image.qcow2")
code = "import fcntl, sys; f = open(sys.argv[1], 'a+b'); fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)"
async with image_lock(path) as lock:
lock_path = lock._file.name
result = await asyncio.to_thread(subprocess.run, [sys.executable, "-c", code, lock_path], capture_output=True)
assert result.returncode != 0
assert b"BlockingIOError" in result.stderr
result = await asyncio.to_thread(subprocess.run, [sys.executable, "-c", code, lock_path], capture_output=True)
assert result.returncode == 0
async def test_shutdown_during_hash_waits_for_worker_before_releasing_lock(inventory, config):
image_file(config)
entered = threading.Event()
exited = threading.Event()
def slow_hash(path, expected, raw, stopped):
entered.set()
stopped.wait(5)
exited.set()
raise InterruptedError("stopped")
with patch("gns3server.services.image_reconciliation.inspect_image_file", side_effect=slow_hash):
job = await inventory.start()
assert await asyncio.to_thread(entered.wait, 5)
await inventory.close()
assert exited.is_set()
assert (await inventory.get_job(job["job_id"]))["status"] == "cancelled"
assert await rows(inventory) == []
async def test_appliance_fallback_repairs_existing_row_and_rejects_wrong_content(inventory, config):
from gns3server.controller.appliance_manager import ApplianceManager
from gns3server.controller.controller_error import ControllerError
path = image_file(config)
await scan(inventory)
old_id = (await rows(inventory))[0]["image_id"]
data = QCOW + b"replacement"
path.write_bytes(data)
appliance = SimpleNamespace(
images=[{"filename": path.name, "md5sum": hashlib.md5(data, usedforsecurity=False).hexdigest()}]
)
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
await ApplianceManager()._find_appliance_version_images(
appliance, {"images": {"hda_disk_image": path.name}}, ImagesRepository(session), str(path.parent)
)
assert (await rows(inventory))[0]["image_id"] == old_id
appliance.images[0]["md5sum"] = "0" * 32
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
with pytest.raises(ControllerError, match="checksum"):
await ApplianceManager()._find_appliance_version_images(
appliance, {"images": {"hda_disk_image": path.name}}, ImagesRepository(session), str(path.parent)
)
async def test_legacy_path_spelling_preserves_identity(inventory, config):
path = image_file(config)
legacy_path = str(path.parent) + os.sep + "." + os.sep + path.name
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
original = await ImagesRepository(session).add_image(path.name, "qemu", len(QCOW), legacy_path, "0" * 32, "md5")
original_id = original.image_id
await scan(inventory)
images = await rows(inventory)
assert len(images) == 1
assert images[0]["image_id"] == original_id
assert images[0]["path"] == legacy_path
assert images[0]["checksum"] == hashlib.md5(QCOW, usedforsecurity=False).hexdigest()
async def test_root_disappearing_during_scan_does_not_mark_images_missing(inventory, config):
first = image_file(config, "QEMU/first.qcow2")
image_file(config, "QEMU/second.qcow2")
await scan(inventory)
first.unlink()
root = config.settings.Server.images_path
original = inventory._file
async def disconnect_root(*args):
await original(*args)
os.rename(root, root + "-disconnected")
with patch.object(inventory, "_file", side_effect=disconnect_root):
result = await scan(inventory)
assert result["status"] == "partial"
assert result["counts"]["missing"] == 0
images = await rows(inventory)
assert next(image for image in images if image["path"] == str(first))["availability"] == "unavailable"
async def test_polling_recovers_crashed_job_without_starting_a_scan(inventory, config):
image_file(config)
job = await scan(inventory)
async with AsyncSession(inventory.engine) as session:
row = await session.get(ImageSyncJob, job["job_id"])
row.status = "running"
await session.commit()
assert not config.settings.Server.auto_discover_images
assert (await inventory.get_job(job["job_id"]))["status"] == "interrupted"
async def test_periodic_scan_recovers_without_watcher_events(inventory, config):
config.settings.Server.auto_discover_images = True
image_file(config)
# Use a short interval and initial wait in the service, leaving asyncio's
# event-loop scheduling and database operations intact.
real_sleep = asyncio.sleep
async def short_sleep(seconds):
await real_sleep(0.01)
scans = []
original_start = inventory.start
async def observed_start(*args, **kwargs):
job = await original_start(*args, **kwargs)
scans.append(job["job_id"])
return job
async def missed_event(*args, **kwargs):
# Consume/close the unused Event.wait coroutine, then simulate a timeout.
args[0].close()
await real_sleep(0.01)
raise asyncio.TimeoutError
with (
patch.object(inventory, "_watch", new=AsyncMock()),
patch.object(inventory, "start", side_effect=observed_start),
patch("gns3server.services.image_reconciliation.asyncio.sleep", side_effect=short_sleep),
patch("gns3server.services.image_reconciliation.asyncio.wait_for", side_effect=missed_event),
):
inventory.start_background()
for _ in range(500):
if len(scans) >= 2:
break
await real_sleep(0.01)
await inventory.close()
assert len(scans) >= 2
assert len(await rows(inventory)) == 1
async def test_unchanged_collection_avoids_hashing_and_keeps_loop_responsive(inventory, config):
import time
for index in range(200):
image_file(config, f"QEMU/bulk/{index}.qcow2", QCOW + b"x" * 65536)
samples = []
done = asyncio.Event()
async def heartbeat():
previous = time.monotonic()
while not done.is_set():
await asyncio.sleep(0.01)
current = time.monotonic()
samples.append(current - previous)
previous = current
pulse = asyncio.create_task(heartbeat())
try:
first = await scan(inventory)
second = await scan(inventory)
finally:
done.set()
await pulse
assert first["counts"]["added"] == 200
assert second["counts"]["unchanged"] == 200
assert second["counts"]["bytes_hashed"] == 0
assert len(samples) > 2
# Generous stall threshold; this checks responsiveness rather than host speed.
assert max(samples) < 2
async def test_startup_upgrades_unversioned_existing_catalog(inventory, config, monkeypatch):
from alembic import command
from alembic.config import Config
from fastapi import FastAPI
from gns3server.db.tasks import connect_to_db, disconnect_from_db
image_file(config)
await scan(inventory)
original_id = (await rows(inventory))[0]["image_id"]
def remove_inventory_schema(connection):
cfg = Config()
cfg.set_main_option("script_location", "gns3server:db_migrations")
cfg.attributes["connection"] = connection
command.stamp(cfg, "d9e8a2b7c401")
command.downgrade(cfg, "c7e4a9f1d2b6")
connection.execute(text("DROP TABLE alembic_version"))
async with inventory.engine.connect() as connection:
await connection.run_sync(remove_inventory_schema)
await connection.commit()
monkeypatch.setenv("GNS3_DATABASE_URI", str(inventory.engine.url))
app = FastAPI()
await connect_to_db(app)
try:
restored = (await rows(inventory))[0]
assert restored["image_id"] == original_id
assert restored["availability"] == "unknown"
finally:
await disconnect_from_db(app)
@pytest.mark.parametrize("operation", ["delete", "prune"])
@pytest.mark.parametrize("reuse_id", [False, True])
async def test_delete_preserves_replacement_created_while_waiting_for_lock(inventory, config, operation, reuse_id):
from contextlib import asynccontextmanager
from sqlalchemy import delete
from gns3server.api.routes.controller.images import delete_image
from gns3server.controller.controller_error import ControllerError
path = image_file(config)
await scan(inventory)
original = (await rows(inventory))[0]
replacement = QCOW + b"replacement uploaded by another request"
replacement_id = original["image_id"] if reuse_id else original["image_id"] + 100
@asynccontextmanager
async def concurrent_replacement(locked_path):
assert locked_path == str(path)
async with AsyncSession(inventory.engine) as writer:
await writer.execute(delete(Image).where(Image.image_id == original["image_id"]))
path.write_bytes(replacement)
info = inspect_image_file(str(path))
info["filename"] = info.pop("image_name")
writer.add(Image(image_id=replacement_id, **info))
await writer.commit()
yield
module = "gns3server.api.routes.controller.images" if operation == "delete" else "gns3server.db.repositories.images"
with (
patch(module + ".image_lock", concurrent_replacement),
patch(
"gns3server.api.routes.controller.images.Controller.instance",
return_value=SimpleNamespace(find_projects_using_image=lambda filename: []),
),
):
async with AsyncSession(inventory.engine, expire_on_commit=False) as session:
repository = ImagesRepository(session)
if operation == "delete":
with pytest.raises(ControllerError, match="changed"):
await delete_image(str(path), repository)
else:
assert await repository.prune_images() == 0
assert path.read_bytes() == replacement
assert (await rows(inventory))[0]["image_id"] == replacement_id