feat: forbid deleting templates and images still used by projects

Deleting a template with prune_images, deleting an image, or pruning
orphan images only checked template references — a project node still
pointing at the image (e.g. via hda_disk_image_backing_file) was left
with a dangling reference and the project could no longer be opened.

- Add controller helpers scanning every known project (opened projects
  via in-memory nodes, closed projects via their .gns3 file) for
  template and image usage; unreadable topologies are skipped
- Guard DELETE /templates/{id}, DELETE /templates/{id}?prune_images,
  DELETE /images/{path} and /images/prune with a 409 listing the
  project names
- Run all template-delete checks before any mutation so a refused
  deletion cannot leave the template gone while its images survive
This commit is contained in:
YueGuobin committed 2026-08-31 22:30:42 +08:00
1 parent 861b7e86ff
commit 13548deae8
8 files changed
+455 -14

No files matched your search

+108
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,84 @@ 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:
project_names.append(project.name)
break
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):
project_names.append(project.name)
break
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):
"""