From da3a34144b39e89c800c60c7303cdc4e21e4a996 Mon Sep 17 00:00:00 2001 From: Cristi Date: Sat, 6 Jun 2026 14:21:15 +0300 Subject: [PATCH 1/3] fix(templates): Database error detected when saving a template with a disk image change --- gns3server/db/repositories/images.py | 2 +- gns3server/db/repositories/templates.py | 4 +++- gns3server/services/templates.py | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/gns3server/db/repositories/images.py b/gns3server/db/repositories/images.py index 8f6c64dc9..de4e3c3d6 100644 --- a/gns3server/db/repositories/images.py +++ b/gns3server/db/repositories/images.py @@ -48,7 +48,7 @@ class ImagesRepository(BaseRepository): else: query = select(models.Image).where(models.Image.filename == image_name) result = await self._db_session.execute(query) - return result.scalars().one_or_none() + return result.scalars().first() async def get_image_by_checksum(self, checksum: str, image_dir: str = None) -> Optional[models.Image]: """ diff --git a/gns3server/db/repositories/templates.py b/gns3server/db/repositories/templates.py index ec8215af8..e4da33d2c 100644 --- a/gns3server/db/repositories/templates.py +++ b/gns3server/db/repositories/templates.py @@ -124,7 +124,9 @@ class TemplatesRepository(BaseRepository): else: query = select(models.Image).where(models.Image.filename == image_name) result = await self._db_session.execute(query) - return result.scalars().one_or_none() + # Use first() instead of one_or_none() to handle cases where multiple + # DB rows share the same filename (e.g. image discovered in multiple paths) + return result.scalars().first() async def add_image_to_template( self, diff --git a/gns3server/services/templates.py b/gns3server/services/templates.py index 92b78633e..6502b0811 100644 --- a/gns3server/services/templates.py +++ b/gns3server/services/templates.py @@ -267,9 +267,13 @@ class TemplatesService: raise ControllerNotFoundError(f"Template '{template_id}' not found") return template - async def _remove_image(self, template_id: UUID, image_path:str) -> None: + async def _remove_image(self, template_id: UUID, image_path: str) -> None: + if not image_path: + return image = await self._templates_repo.get_image(image_path) + if image is None: + return await self._templates_repo.remove_image_from_template(template_id, image) async def update_template(self, template_id: UUID, template_update: schemas.TemplateUpdate) -> dict: From 1e40fa1b706acaa831d54f5277a01a7fd4e57512 Mon Sep 17 00:00:00 2001 From: Cristi Date: Sat, 6 Jun 2026 14:46:27 +0300 Subject: [PATCH 2/3] fix (templates): Add ordering to handle the duplicate cases gracefully --- gns3server/db/repositories/images.py | 10 +++++++++- gns3server/db/repositories/templates.py | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/gns3server/db/repositories/images.py b/gns3server/db/repositories/images.py index de4e3c3d6..266f09997 100644 --- a/gns3server/db/repositories/images.py +++ b/gns3server/db/repositories/images.py @@ -47,8 +47,16 @@ class ImagesRepository(BaseRepository): where(models.Image.filename == image_name, models.Image.path.endswith(image_path)) else: query = select(models.Image).where(models.Image.filename == image_name) + query = query.order_by(models.Image.image_id) result = await self._db_session.execute(query) - return result.scalars().first() + images = result.scalars().all() + if len(images) > 1: + log.warning( + f"Multiple DB entries found for image '{image_path}' " + f"({len(images)} rows). This indicates a data integrity issue. " + f"Using the entry with the lowest image_id ({images[0].image_id})." + ) + return images[0] if images else None async def get_image_by_checksum(self, checksum: str, image_dir: str = None) -> Optional[models.Image]: """ diff --git a/gns3server/db/repositories/templates.py b/gns3server/db/repositories/templates.py index e4da33d2c..c141008d0 100644 --- a/gns3server/db/repositories/templates.py +++ b/gns3server/db/repositories/templates.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import os +import logging from uuid import UUID from typing import List, Union, Optional @@ -29,6 +30,8 @@ from .base import BaseRepository import gns3server.db.models as models from gns3server import schemas +log = logging.getLogger(__name__) + TEMPLATE_TYPE_TO_MODEL = { "cloud": models.CloudTemplate, "docker": models.DockerTemplate, @@ -123,10 +126,16 @@ class TemplatesRepository(BaseRepository): where(models.Image.filename == image_name, models.Image.path.endswith(image_path)) else: query = select(models.Image).where(models.Image.filename == image_name) + query = query.order_by(models.Image.image_id) result = await self._db_session.execute(query) - # Use first() instead of one_or_none() to handle cases where multiple - # DB rows share the same filename (e.g. image discovered in multiple paths) - return result.scalars().first() + images = result.scalars().all() + if len(images) > 1: + log.warning( + f"Multiple DB entries found for image '{image_path}' " + f"({len(images)} rows). This indicates a data integrity issue. " + f"Using the entry with the lowest image_id ({images[0].image_id})." + ) + return images[0] if images else None async def add_image_to_template( self, From 03b3078271b39f7b08537d898a4c56b5c0bfaa6e Mon Sep 17 00:00:00 2001 From: Cristi Date: Sat, 6 Jun 2026 15:04:06 +0300 Subject: [PATCH 3/3] fix: revert duplicate image check to fix failing tests --- gns3server/db/repositories/images.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/gns3server/db/repositories/images.py b/gns3server/db/repositories/images.py index 266f09997..8f6c64dc9 100644 --- a/gns3server/db/repositories/images.py +++ b/gns3server/db/repositories/images.py @@ -47,16 +47,8 @@ class ImagesRepository(BaseRepository): where(models.Image.filename == image_name, models.Image.path.endswith(image_path)) else: query = select(models.Image).where(models.Image.filename == image_name) - query = query.order_by(models.Image.image_id) result = await self._db_session.execute(query) - images = result.scalars().all() - if len(images) > 1: - log.warning( - f"Multiple DB entries found for image '{image_path}' " - f"({len(images)} rows). This indicates a data integrity issue. " - f"Using the entry with the lowest image_id ({images[0].image_id})." - ) - return images[0] if images else None + return result.scalars().one_or_none() async def get_image_by_checksum(self, checksum: str, image_dir: str = None) -> Optional[models.Image]: """