Merge pull request #2868 from yueguobin/feat/template-delete-usage-guard

feat: forbid deleting templates and images still used by projects
This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-08-31 19:03:28 +02:00
commit d1b4de6b8f
8 files changed
+478 -14

No files matched your search

+10 -1
View File
@@ -197,11 +197,16 @@ async def prune_images(
"""
Prune images not attached to any template.
Images referenced by a node in any project (opened or closed) are kept.
Required privilege: Image.Allocate
"""
skip_images = get_builtin_disks()
await images_repo.prune_images(skip_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))
@router.post(
@@ -309,6 +314,10 @@ async def delete_image(
template_names = ", ".join([template.name for template in templates])
raise ControllerError(f"Image '{image_path}' is used by one or more templates: {template_names}")
project_names = Controller.instance().find_projects_using_image(image.filename)
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:
+35 -13
View File
@@ -35,6 +35,7 @@ from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.services.templates import TemplatesService
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.images import ImagesRepository
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
from gns3server.utils.images import get_builtin_disks
@@ -135,28 +136,49 @@ async def delete_template(
Required privilege: Template.Allocate
"""
controller = Controller.instance()
images = await templates_repo.get_template_images(template_id)
await TemplatesService(templates_repo).delete_template(template_id)
await rbac_repo.delete_all_ace_starting_with_path(f"/templates/{template_id}")
# Run every usage check before mutating anything, so a refused deletion
# cannot leave the template gone while its images survive.
images_to_prune = []
if prune_images and images:
skip_images = get_builtin_disks()
# collected lazily: a single pass over all projects' node properties
referenced_filenames = None
for image in images:
if image.filename in skip_images:
continue
templates = await images_repo.get_image_templates(image.image_id)
if templates:
template_names = ", ".join([template.name for template in templates])
# the template being deleted is still in the database at this
# point, exclude it from the other-templates check
other_templates = [
template for template in await images_repo.get_image_templates(image.image_id)
if str(template.template_id) != str(template_id)
]
if other_templates:
template_names = ", ".join([template.name for template in other_templates])
raise ControllerError(f"Image '{image.path}' is used by one or more templates: {template_names}")
try:
os.remove(image.path)
except OSError:
log.warning(f"Could not delete image file {image.path}")
if referenced_filenames is None:
referenced_filenames = controller.collect_referenced_image_filenames()
if image.filename in referenced_filenames:
project_names = controller.find_projects_using_image(image.filename)
raise ControllerError(f"Image '{image.path}' is used by one or more projects: {', '.join(project_names)}")
images_to_prune.append(image)
print(f"Deleting image '{image.path}'")
success = await images_repo.delete_image(image.path)
if not success:
raise ControllerError(f"Image '{image.path}' could not removed from the database")
await TemplatesService(templates_repo).delete_template(template_id)
await rbac_repo.delete_all_ace_starting_with_path(f"/templates/{template_id}")
for image in images_to_prune:
try:
os.remove(image.path)
except OSError:
log.warning(f"Could not delete image file {image.path}")
log.info(f"Deleting image '{image.path}'")
success = await images_repo.delete_image(image.path)
if not success:
raise ControllerError(f"Image '{image.path}' could not removed from the database")
@router.get(
+106
View File
@@ -38,6 +38,7 @@ from ..utils.images import default_images_directory
from ..utils.asyncio import wait_run_in_executor
from .project import Project
from .node import Node
from .appliance import Appliance
from .appliance_manager import ApplianceManager
from .compute import Compute, ComputeError
@@ -91,6 +92,35 @@ class _ProjectsDirectoryEventHandler(FileSystemEventHandler):
return
def _iter_string_values(value):
"""
Yield every string contained in value, recursing into dicts and lists.
"""
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for child in value.values():
yield from _iter_string_values(child)
elif isinstance(value, (list, tuple, set)):
for child in value:
yield from _iter_string_values(child)
def _image_referenced(node_properties, image_filename: str) -> bool:
"""
Whether a node's properties reference an image by file name.
Image references live under type-specific property keys
(hda_disk_image, hda_disk_image_backing_file, image, initrd, path...)
and are stored either as bare file names or as absolute paths
depending on topology age, so every string value is matched on its
base name instead of walking a fixed key list.
"""
return any(os.path.basename(string) == image_filename for string in _iter_string_values(node_properties))
class Controller:
"""
The controller is responsible to manage one or more computes.
@@ -811,6 +841,82 @@ class Controller:
raise ControllerError("A project name could not be allocated (node limit reached?)")
return new_name
def _iter_project_nodes(self):
"""
Iterate over (project, node) for every known project, opened or closed.
Opened projects yield controller Node objects; closed projects yield
the raw node dicts read from their .gns3 file. A project whose
topology cannot be read is skipped — it must not block the usage
checks that rely on this iterator.
"""
for project in self._projects.values():
try:
nodes = project.nodes.values()
except ControllerError:
continue
for node in nodes:
yield project, node
def find_projects_using_template(self, template_id) -> list:
"""
Return the names of the projects with at least one node created
from this template.
Used to forbid template deletion while any project still
references it.
"""
template_id = str(template_id)
project_names = []
for project, node in self._iter_project_nodes():
if isinstance(node, Node):
node_template_id = node.template_id
else:
node_template_id = node.get("template_id")
if node_template_id and str(node_template_id) == template_id and project.name not in project_names:
project_names.append(project.name)
return project_names
def find_projects_using_image(self, image_filename: str) -> list:
"""
Return the names of the projects with at least one node whose
properties reference this image file.
Used to forbid image deletion while any project still uses it.
"""
project_names = []
for project, node in self._iter_project_nodes():
if isinstance(node, Node):
node_properties = node.properties
else:
node_properties = node.get("properties") or {}
if _image_referenced(node_properties, image_filename) and project.name not in project_names:
project_names.append(project.name)
return project_names
def collect_referenced_image_filenames(self) -> set:
"""
Return every file name referenced by a node property across all
projects (opened or closed).
The set deliberately over-approximates (any property string counts):
it protects images from pruning, so a false positive only keeps a
file alive. Used as a single-pass skip list for image pruning.
"""
filenames = set()
for _project, node in self._iter_project_nodes():
if isinstance(node, Node):
node_properties = node.properties
else:
node_properties = node.get("properties") or {}
for string in _iter_string_values(node_properties):
filenames.add(os.path.basename(string))
return filenames
@property
def projects(self):
"""
+4
View File
@@ -162,6 +162,10 @@ class Node:
def status(self):
return self._status
@property
def template_id(self):
return self._template_id
@property
def name(self):
return self._name
+9
View File
@@ -341,6 +341,15 @@ class TemplatesService:
if self.get_builtin_template(template_id):
raise ControllerForbiddenError(f"Template '{template_id}' cannot be deleted because it is built-in")
template = await self.get_template(template_id)
project_names = self._controller.find_projects_using_template(template_id)
if project_names:
raise ControllerError(
f"Template '{template['name']}' cannot be deleted because it is used by "
f"one or more projects: {', '.join(project_names)}"
)
if await self._templates_repo.delete_template(template_id):
self._controller.notification.controller_emit("template.deleted", {"template_id": str(template_id)})
else: