From 6f7730c455f672ce1b651200a0ebf97066b410d7 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 9 Apr 2026 23:18:55 +0800 Subject: [PATCH] Update snapshots when duplicating a project --- gns3server/controller/__init__.py | 2 +- gns3server/controller/import_project.py | 77 ++++++++----- gns3server/controller/node.py | 2 +- gns3server/controller/project.py | 147 +++++++++++------------- 4 files changed, 115 insertions(+), 113 deletions(-) diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 05b0dfe29..34b5144b9 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -217,7 +217,7 @@ class Controller: try: os.makedirs(os.path.dirname(self._config_file), exist_ok=True) with open(self._config_file, 'w+') as f: - json.dump(controller_settings, f, indent=4) + json.dump(controller_settings, f, indent=4, sort_keys=True) except OSError as e: log.error("Cannot write controller configuration file '{}': {}".format(self._config_file, e)) diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index de022dca0..77dfde1fb 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -117,26 +117,7 @@ async def import_project( if not restoring_snapshot: # Do not re-generate IDs if we are restoring a snapshot because they should be the same as the main project - - # Generate a new node id - node_old_to_new = {} - for node in topology["topology"]["nodes"]: - if "node_id" in node: - node_old_to_new[node["node_id"]] = str(uuid.uuid4()) - _move_node_file(path, node["node_id"], node_old_to_new[node["node_id"]]) - node["node_id"] = node_old_to_new[node["node_id"]] - else: - node["node_id"] = str(uuid.uuid4()) - - # Update link to use new id - for link in topology["topology"]["links"]: - link["link_id"] = str(uuid.uuid4()) - for node in link["nodes"]: - node["node_id"] = node_old_to_new[node["node_id"]] - - # Generate new drawings id - for drawing in topology["topology"]["drawings"]: - drawing["drawing_id"] = str(uuid.uuid4()) + regenerate_ids(topology, path, reset_mac_addresses=True) # Modify the compute id of the node depending on compute capacity if not keep_compute_ids: @@ -175,7 +156,7 @@ async def import_project( # We change the project_id to avoid erasing the project topology["project_id"] = project_id with open(dot_gns3_path, "w+") as f: - json.dump(topology, f, indent=4) + json.dump(topology, f, indent=4, sort_keys=True) os.remove(os.path.join(path, "project.gns3")) images_path = os.path.join(path, "images") @@ -183,8 +164,8 @@ async def import_project( await _import_images(controller, images_path) snapshots_path = os.path.join(path, "snapshots") - if os.path.exists(snapshots_path): - await _import_snapshots(snapshots_path, project_name, project_id) + if not restoring_snapshot and os.path.exists(snapshots_path): + await update_snapshots(snapshots_path, path, project_name, project_id) project = await controller.load_project(dot_gns3_path, load=False) return project @@ -208,6 +189,40 @@ def _create_symbolic_links(zip_file, path): except OSError as e: raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}") +def regenerate_ids(topology, new_project_path, reset_mac_addresses=False): + """ + Regenerate IDs in the topology and move the files of the nodes to match the new IDs + + :param topology: topology content + :param new_project_path: new project path + :param reset_mac_addresses: reset MAC addresses + """ + + # Generate new node IDs + node_old_to_new = {} + for node in topology["topology"]["nodes"]: + new_node_id = str(uuid.uuid4()) + if "node_id" in node: + node_old_to_new[node["node_id"]] = new_node_id + _move_node_file(new_project_path, node["node_id"], new_node_id) + node["node_id"] = new_node_id + if reset_mac_addresses: + if "properties" in node: + for prop, value in node["properties"].items(): + # reset the MAC address + if prop in ("mac_addr", "mac_address"): + node["properties"][prop] = None + + # Generate new link IDs + for link in topology["topology"]["links"]: + link["link_id"] = str(uuid.uuid4()) + for node in link["nodes"]: + node["node_id"] = node_old_to_new[node["node_id"]] + + # Generate new drawings IDs + for drawing in topology["topology"]["drawings"]: + drawing["drawing_id"] = str(uuid.uuid4()) + def _move_node_file(path, old_id, new_id): """ Move a file from a node when changing its id @@ -274,16 +289,17 @@ async def _import_images(controller, images_path): await wait_run_in_executor(shutil.move, path, dst) -async def _import_snapshots(snapshots_path, project_name, project_id): +async def update_snapshots(snapshots_dir, project_path, project_name, project_id): """ - Import the snapshots and update their project name and ID to be the same as the main project. + Load the snapshots and update their project name and project ID to be the same as the main project. + Regenerate all the node, link and drawing IDs """ - for snapshot in os.listdir(snapshots_path): - if not snapshot.endswith(".gns3project"): + for snapshot in os.listdir(snapshots_dir): + if not (snapshot.endswith(".gns3snapshot") or snapshot.endswith(".gns3project")): continue - snapshot_path = os.path.join(snapshots_path, snapshot) - with tempfile.TemporaryDirectory(dir=snapshots_path) as tmpdir: + snapshot_path = os.path.join(snapshots_dir, snapshot) + with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir: # extract everything to a temporary directory try: @@ -301,9 +317,9 @@ async def _import_snapshots(snapshots_path, project_name, project_id): topology_file_path = os.path.join(tmpdir, "project.gns3") with open(topology_file_path, encoding="utf-8") as f: topology = json.load(f) - topology["name"] = project_name topology["project_id"] = project_id + regenerate_ids(topology, project_path, reset_mac_addresses=False) with open(topology_file_path, "w+", encoding="utf-8") as f: json.dump(topology, f, indent=4, sort_keys=True) except OSError as e: @@ -321,5 +337,6 @@ async def _import_snapshots(snapshots_path, project_name, project_id): async with aiofiles.open(snapshot_path, 'wb+') as f: async for chunk in zstream: await f.write(chunk) + log.info("Project '{}': updated and repacked snapshot file '{}'".format(project_name, snapshot)) except OSError as e: raise aiohttp.web.HTTPConflict(text="Cannot update snapshot '{}': the snapshot cannot be recreated: {}".format(os.path.basename(snapshot), e)) diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index eb5bad696..a34fb83ab 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -91,7 +91,7 @@ class Node: self._first_port_name = None self._console_auto_start = False - # This properties will be recompute + # This properties will be recomputed ignore_properties = ("width", "height", "hover_symbol") self.properties = kwargs.pop('properties', {}) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index dfcfbaa7c..f6b9e458b 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sys import re import os import json @@ -46,7 +45,7 @@ from ..utils.asyncio import locking from ..utils.asyncio import aiozipstream from ..utils.asyncio import wait_run_in_executor from .export_project import export_project -from .import_project import import_project, _move_node_file +from .import_project import import_project, update_snapshots, regenerate_ids import logging log = logging.getLogger(__name__) @@ -794,18 +793,15 @@ class Project: continue self._snapshots[snapshot.id] = snapshot else: - # Create the snapshot instances from the snapshot config file + # Create the Snapshot instances from the snapshot config file for snapshot_entry in self._snapshot_conf: try: - name = snapshot_entry["name"] - snapshot_id = snapshot_entry["snapshot_id"] - created_at = snapshot_entry["created_at"] - filename = snapshot_entry["filename"] - path = os.path.join(snapshot_dir, filename) + path = os.path.join(snapshot_dir, snapshot_entry["filename"]) if not os.path.isfile(path): log.warning("Snapshot file '{}' does not exist".format(path)) continue - snapshot = Snapshot(self, name=name, filename=filename, snapshot_id=snapshot_id, created_at=created_at) + snapshot_entry.pop("project_id") + snapshot = Snapshot(self, **snapshot_entry) self._snapshots[snapshot.id] = snapshot except KeyError: log.error("Invalid entry in snapshot config file: {}".format(snapshot_entry)) @@ -823,7 +819,7 @@ class Project: self._snapshot_conf.append(snapshot.__json__()) try: with open(self._snapshot_conf_path, 'w+') as f: - json.dump(self._snapshot_conf, f, indent=4) + json.dump(self._snapshot_conf, f, indent=4, sort_keys=True) except OSError as e: log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e)) @@ -938,7 +934,7 @@ class Project: def _get_default_project_directory(cls): """ Return the default location for the project directory - depending of the operating system + depending on the operating system """ server_config = Config.instance().get_section_config("Server") @@ -1139,11 +1135,12 @@ class Project: async for chunk in zstream: await f.write(chunk) + new_project_id = str(uuid.uuid4()) # import the temporary project with open(project_path, "rb") as f: - project = await import_project(self._controller, str(uuid.uuid4()), f, location=location, name=name, keep_compute_ids=True) + project = await import_project(self._controller, new_project_id, f, location=location, name=name, keep_compute_ids=True) - log.info("Project '{}' duplicated in {:.4f} seconds".format(project.name, time.time() - begin)) + log.info("Project '{}': duplicated in {:.4f} seconds".format(project.name, time.time() - begin)) except (ValueError, OSError, UnicodeEncodeError) as e: raise aiohttp.web.HTTPConflict(text="Cannot duplicate project: {}".format(str(e))) @@ -1152,6 +1149,62 @@ class Project: return project + async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True): + """ + Fast duplication of a project. + + Copy the project files directly rather than in an import-export fashion. + + :param name: Name of the new project. A new one will be generated in case of conflicts + :param location: Parent directory of the new project + :param reset_mac_addresses: Reset MAC addresses for the new project + """ + + # remote replication is not supported with remote computes + for compute in self.computes: + if compute.id != "local": + log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id)) + return None + # work dir + p_work = pathlib.Path(location or self.path).parent.absolute() + t0 = time.time() + new_project_id = str(uuid.uuid4()) + if location: + new_project_path = p_work.joinpath(location) + else: + new_project_path = p_work.joinpath(new_project_id) + # copy dir + await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True) + log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0)) + topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes()) + project_name = name or topology["name"] + # If the project name is already used we generate a new one + project_name = self.controller.get_free_project_name(project_name) + topology["name"] = project_name + # To avoid unexpected behavior (project start without manual operations just after import) + topology["auto_start"] = False + topology["auto_open"] = False + topology["auto_close"] = False + + # regenerate IDs for the duplicated project + regenerate_ids(topology, new_project_path, reset_mac_addresses) + + # dump the updated .gns3 project file + dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name)) + topology["project_id"] = new_project_id + with open(dot_gns3_path, "w+") as f: + json.dump(topology, f, indent=4, sort_keys=True) + + # update the snapshots with new IDs + snapshots_dir = os.path.join(new_project_path, "snapshots") + if os.path.isdir(snapshots_dir): + await update_snapshots(snapshots_dir, new_project_path, project_name, new_project_id) + + os.remove(new_project_path.joinpath('{}.gns3'.format(self.name))) + project = await self.controller.load_project(dot_gns3_path, load=False) + log.info("Project '{}': fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0)) + return project + def is_running(self): """ If a node is started or paused return True @@ -1302,72 +1355,4 @@ class Project: def __repr__(self): return "".format(self._name, self._id) - async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True): - """ - Fast duplication of a project. - Copy the project files directly rather than in an import-export fashion. - - :param name: Name of the new project. A new one will be generated in case of conflicts - :param location: Parent directory of the new project - :param reset_mac_addresses: Reset MAC addresses for the new project - """ - - # remote replication is not supported with remote computes - for compute in self.computes: - if compute.id != "local": - log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id)) - return None - # work dir - p_work = pathlib.Path(location or self.path).parent.absolute() - t0 = time.time() - new_project_id = str(uuid.uuid4()) - if location: - new_project_path = p_work.joinpath(location) - else: - new_project_path = p_work.joinpath(new_project_id) - # copy dir - await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True) - log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0)) - topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes()) - project_name = name or topology["name"] - # If the project name is already used we generate a new one - project_name = self.controller.get_free_project_name(project_name) - topology["name"] = project_name - # To avoid unexpected behavior (project start without manual operations just after import) - topology["auto_start"] = False - topology["auto_open"] = False - topology["auto_close"] = False - # change node ID - node_old_to_new = {} - for node in topology["topology"]["nodes"]: - new_node_id = str(uuid.uuid4()) - if "node_id" in node: - node_old_to_new[node["node_id"]] = new_node_id - _move_node_file(new_project_path, node["node_id"], new_node_id) - node["node_id"] = new_node_id - if reset_mac_addresses: - if "properties" in node: - for prop, value in node["properties"].items(): - # reset the MAC address - if prop in ("mac_addr", "mac_address"): - node["properties"][prop] = None - # change link ID - for link in topology["topology"]["links"]: - link["link_id"] = str(uuid.uuid4()) - for node in link["nodes"]: - node["node_id"] = node_old_to_new[node["node_id"]] - # Generate new drawings id - for drawing in topology["topology"]["drawings"]: - drawing["drawing_id"] = str(uuid.uuid4()) - - # And we dump the updated.gns3 - dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name)) - topology["project_id"] = new_project_id - with open(dot_gns3_path, "w+") as f: - json.dump(topology, f, indent=4) - - os.remove(new_project_path.joinpath('{}.gns3'.format(self.name))) - project = await self.controller.load_project(dot_gns3_path, load=False) - log.info("Project '{}' fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0)) - return project