From 2670c68467f77d9eb4723a6a69b8d432b01f4413 Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 10 Apr 2026 18:58:21 +0800 Subject: [PATCH] Last adjustments for import/export --- gns3server/controller/export_project.py | 37 +++++++++++++++++++--- gns3server/controller/import_project.py | 38 +++++++++++------------ gns3server/controller/node.py | 1 + gns3server/controller/project.py | 41 +++++++++++++++++-------- gns3server/controller/snapshot.py | 6 ++-- 5 files changed, 83 insertions(+), 40 deletions(-) diff --git a/gns3server/controller/export_project.py b/gns3server/controller/export_project.py index 1a7c43995..d703a4749 100644 --- a/gns3server/controller/export_project.py +++ b/gns3server/controller/export_project.py @@ -32,7 +32,16 @@ log = logging.getLogger(__name__) CHUNK_SIZE = 1024 * 8 # 8KB -async def export_project(zstream, project, temporary_dir, include_images=False, include_snapshots=False, keep_compute_ids=False, allow_all_nodes=False, reset_mac_addresses=False): +async def export_project( + zstream, + project, + temporary_dir, + include_images=False, + include_snapshots=False, + keep_compute_ids=False, + allow_all_nodes=False, + reset_mac_addresses=False +): """ Export a project to a zip file. @@ -46,7 +55,7 @@ async def export_project(zstream, project, temporary_dir, include_images=False, :param include_snapshots: save snapshots to the zip file :param keep_compute_ids: If false replace all compute IDs y local (standard behavior for .gns3project to make it portable) :param allow_all_nodes: Allow all nodes type to be included in the zip even if not portable - :param reset_mac_addresses: Reset MAC addresses for each node. + :param reset_mac_addresses: Reset MAC addresses for each node """ # To avoid issue with data not saved we disallow the export of a running project @@ -62,7 +71,16 @@ async def export_project(zstream, project, temporary_dir, include_images=False, # First we process the .gns3 in order to be sure we don't have an error for file in os.listdir(project._path): if file.endswith(".gns3"): - await _patch_project_file(project, os.path.join(project._path, file), zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses) + await _patch_project_file( + project, + os.path.join(project._path, file), + zstream, + include_images, + keep_compute_ids, + allow_all_nodes, + temporary_dir, + reset_mac_addresses + ) # Export the local files for root, dirs, files in os.walk(project._path, topdown=True, followlinks=False): @@ -171,7 +189,16 @@ def _is_exportable(path, include_snapshots=False): return True -async def _patch_project_file(project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses): +async def _patch_project_file( + project, + path, + zstream, + include_images, + keep_compute_ids, + allow_all_nodes, + temporary_dir, + reset_mac_addresses +): """ Patch a project file (.gns3) to export a project. The .gns3 file is renamed to project.gns3 @@ -244,7 +271,7 @@ async def _patch_project_file(project, path, zstream, include_images, keep_compu for compute_id, image_type, image in remote_images: await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir) - zstream.writestr("project.gns3", json.dumps(topology).encode()) + zstream.writestr("project.gns3", json.dumps(topology, indent=4, sort_keys=True).encode()) return images diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index 77dfde1fb..50e259ba3 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -45,12 +45,11 @@ async def import_project( stream, location=None, name=None, + reset_mac_addresses=False, keep_compute_ids=False, - restoring_snapshot=False, - project_name=None, auto_start=False, auto_open=False, - auto_close=True + auto_close=True, ): """ Import a project contain in a zip file @@ -62,8 +61,8 @@ async def import_project( :param stream: A io.BytesIO of the zipfile :param location: Directory for the project if None put in the default directory :param name: Wanted project name, generate one from the .gns3 if None + :param reset_mac_addresses: Reset MAC addresses for each node :param keep_compute_ids: keep compute IDs unchanged - :param restoring_snapshot: True if the project is imported as part of a snapshot restore, False otherwise :param project_name: Original project name when restoring a snapshot :returns: Project @@ -82,12 +81,18 @@ async def import_project( try: topology = json.loads(project_file) - if not project_name: + # We import the project on top of an existing project (snapshots) + if topology["project_id"] == project_id: + project_name = topology["name"] + log.info("Restoring snapshot for project '{}', snapshot name: '{}'".format(project_id, project_name)) + restoring_snapshot = True + else: # If the project name is already used we generate a new one if name: project_name = controller.get_free_project_name(name) else: project_name = controller.get_free_project_name(topology["name"]) + restoring_snapshot = False except (ValueError, KeyError): raise aiohttp.web.HTTPConflict(text="Cannot import project, the project.gns3 file is corrupted") @@ -116,8 +121,8 @@ async def import_project( topology["auto_close"] = auto_close 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 - regenerate_ids(topology, path, reset_mac_addresses=True) + # Do not re-generate IDs if we are restoring a snapshot because they should be the same in a project + regenerate_topology_ids(topology, path, reset_mac_addresses=reset_mac_addresses) # Modify the compute id of the node depending on compute capacity if not keep_compute_ids: @@ -129,14 +134,6 @@ async def import_project( node["compute_id"] = "vm" else: # Round-robin through available compute resources. - # computes = [] - # for compute_id in controller.computes: - # compute = controller.get_compute(compute_id) - # # only use the local compute or any connected compute - # if compute_id == "local" or compute.connected: - # computes.append(compute_id) - # else: - # log.warning(compute.name, "is not connected!") compute_nodes = itertools.cycle(controller.computes) for node in topology["topology"]["nodes"]: node["compute_id"] = next(compute_nodes) @@ -165,7 +162,7 @@ async def import_project( snapshots_path = os.path.join(path, "snapshots") if not restoring_snapshot and os.path.exists(snapshots_path): - await update_snapshots(snapshots_path, path, project_name, project_id) + await update_snapshots(snapshots_path, path, project_name, project_id, reset_mac_addresses=reset_mac_addresses) project = await controller.load_project(dot_gns3_path, load=False) return project @@ -189,9 +186,10 @@ 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): +def regenerate_topology_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 + Regenerate IDs in the topology and move the files of the nodes to match the new IDs. + This is necessary because IDs must be unique across projects. :param topology: topology content :param new_project_path: new project path @@ -289,7 +287,7 @@ async def _import_images(controller, images_path): await wait_run_in_executor(shutil.move, path, dst) -async def update_snapshots(snapshots_dir, project_path, project_name, project_id): +async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True): """ 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 @@ -319,7 +317,7 @@ async def update_snapshots(snapshots_dir, project_path, project_name, project_id topology = json.load(f) topology["name"] = project_name topology["project_id"] = project_id - regenerate_ids(topology, project_path, reset_mac_addresses=False) + regenerate_topology_ids(topology, project_path, reset_mac_addresses) with open(topology_file_path, "w+", encoding="utf-8") as f: json.dump(topology, f, indent=4, sort_keys=True) except OSError as e: diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index a34fb83ab..54be7fe02 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -382,6 +382,7 @@ class Node: await self.parse_node_response(response.json) return True trial += 1 + return False async def update(self, **kwargs): """ diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index f6b9e458b..b686069b4 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -45,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, update_snapshots, regenerate_ids +from .import_project import import_project, update_snapshots, regenerate_topology_ids import logging log = logging.getLogger(__name__) @@ -819,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, sort_keys=True) + json.dump(self._snapshot_conf, f, indent=4) except OSError as e: log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e)) @@ -1126,7 +1126,14 @@ class Project: with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir: # Do not compress the exported project when duplicating with aiozipstream.ZipFile(compression=zipfile.ZIP_STORED) as zstream: - await export_project(zstream, self, tmpdir, keep_compute_ids=True, allow_all_nodes=True, reset_mac_addresses=reset_mac_addresses) + await export_project( + zstream, + self, + tmpdir, + keep_compute_ids=True, + include_snapshots=True, + allow_all_nodes=True + ) # export the project to a temporary location project_path = os.path.join(tmpdir, "project.gns3p") @@ -1136,9 +1143,17 @@ class Project: await f.write(chunk) new_project_id = str(uuid.uuid4()) - # import the temporary project + # import the duplicated project with open(project_path, "rb") as f: - project = await import_project(self._controller, new_project_id, f, location=location, name=name, keep_compute_ids=True) + project = await import_project( + self._controller, + new_project_id, + f, + location=location, + name=name, + reset_mac_addresses=reset_mac_addresses, + keep_compute_ids=True + ) log.info("Project '{}': duplicated in {:.4f} seconds".format(project.name, time.time() - begin)) except (ValueError, OSError, UnicodeEncodeError) as e: @@ -1157,7 +1172,7 @@ class Project: :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 + :param reset_mac_addresses: Reset MAC addresses for the duplicated project """ # remote replication is not supported with remote computes @@ -1187,7 +1202,7 @@ class Project: topology["auto_close"] = False # regenerate IDs for the duplicated project - regenerate_ids(topology, new_project_path, reset_mac_addresses) + regenerate_topology_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)) @@ -1303,11 +1318,13 @@ class Project: data['z'] = z data['locked'] = False # duplicated node must not be locked new_node_uuid = str(uuid.uuid4()) - new_node = await self.add_node(node.compute, - node.name, - new_node_uuid, - node_type=node_type, - **data) + new_node = await self.add_node( + node.compute, + node.name, + new_node_uuid, + node_type=node_type, + **data + ) try: await node.post("/duplicate", timeout=None, data={ "destination_node_id": new_node_uuid diff --git a/gns3server/controller/snapshot.py b/gns3server/controller/snapshot.py index 9030c7c0d..8bd44e94c 100644 --- a/gns3server/controller/snapshot.py +++ b/gns3server/controller/snapshot.py @@ -135,6 +135,7 @@ class Snapshot: await self._project.close(ignore_notification=True) try: + begin = time.time() # delete the current project files project_files_path = os.path.join(self._project.path, "project-files") if os.path.exists(project_files_path): @@ -145,12 +146,11 @@ class Snapshot: self._project.id, f, location=self._project.path, - project_name=self._project.name, - restoring_snapshot=True, auto_start=self._project.auto_start, auto_open=self._project.auto_open, auto_close=self._project.auto_close ) + log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin)) except (OSError, PermissionError) as e: raise aiohttp.web.HTTPConflict(text=str(e)) await project.open() @@ -161,8 +161,8 @@ class Snapshot: return { "snapshot_id": self._id, "name": self._name, - "description": self._description, "created_at": self._created_at, + "description": self._description, "filename": self._filename, "project_id": self._project.id }