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/export_project.py b/gns3server/controller/export_project.py index a2d6b5a87..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): @@ -87,6 +105,10 @@ async def export_project(zstream, project, temporary_dir, include_images=False, # save empty directories for directory in dirs: path = os.path.join(root, directory) + if path == temporary_dir: + continue + if include_snapshots is False and path.endswith("snapshots"): + continue if not os.listdir(path): zstream.write(path, os.path.relpath(path, project._path)) except FileNotFoundError as e: @@ -142,7 +164,7 @@ def _is_exportable(path, include_snapshots=False): """ # do not export snapshots by default - if include_snapshots is False and path.endswith("snapshots"): + if include_snapshots is False and os.path.dirname(path).endswith("snapshots"): return False # do not export directories of snapshots @@ -167,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 @@ -240,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 d7e47980e..6efcc2b70 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -39,8 +39,18 @@ Handle the import of project from a .gns3project """ -async def import_project(controller, project_id, stream, location=None, name=None, keep_compute_ids=False, - auto_start=False, auto_open=False, auto_close=True): +async def import_project( + controller, + project_id, + stream, + location=None, + name=None, + reset_mac_addresses=False, + keep_compute_ids=False, + auto_start=False, + auto_open=False, + auto_close=True, +): """ Import a project contain in a zip file @@ -51,7 +61,9 @@ async def import_project(controller, project_id, stream, location=None, name=Non :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 project_name: Original project name when restoring a snapshot :returns: Project """ @@ -72,12 +84,14 @@ async def import_project(controller, project_id, stream, location=None, name=Non # We import the project on top of an existing project (snapshots) if topology["project_id"] == project_id: project_name = topology["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") @@ -105,27 +119,11 @@ async def import_project(controller, project_id, stream, location=None, name=Non topology["auto_open"] = auto_open topology["auto_close"] = auto_close - # 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()) + if not restoring_snapshot: + # 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) - # 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()) - - # Modify the compute id of the node depending of compute capacity + # Modify the compute id of the node depending on compute capacity if not keep_compute_ids: # For some VM type we move them to the GNS3 VM if possible # unless it's a linux host without GNS3 VM @@ -135,14 +133,6 @@ async def import_project(controller, project_id, stream, location=None, name=Non 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) @@ -162,7 +152,7 @@ async def import_project(controller, project_id, stream, location=None, name=Non # 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") @@ -170,8 +160,8 @@ async def import_project(controller, project_id, stream, location=None, name=Non 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, reset_mac_addresses=reset_mac_addresses) project = await controller.load_project(dot_gns3_path, load=False) return project @@ -195,6 +185,41 @@ def _create_symbolic_links(zip_file, path): except OSError as e: raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}") +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. + This is necessary because IDs must be unique across projects. + + :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 @@ -261,16 +286,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, reset_mac_addresses=True): """ - 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: @@ -288,9 +314,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_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: @@ -308,5 +334,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..54be7fe02 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', {}) @@ -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 743758c72..b686069b4 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_topology_ids import logging log = logging.getLogger(__name__) @@ -96,6 +95,7 @@ class Project: self._show_interface_labels = show_interface_labels self._variables = variables self._supplier = supplier + self._snapshots_config_file = "snapshots.conf" self._loading = False self._closing = False @@ -189,19 +189,7 @@ class Project: self._drawings = {} self._snapshots = {} self._computes = [] - - # List the available snapshots - snapshot_dir = os.path.join(self.path, "snapshots") - if os.path.exists(snapshot_dir): - for snap in os.listdir(snapshot_dir): - if snap.endswith(".gns3project"): - try: - snapshot = Snapshot(self, filename=snap) - except ValueError: - log.error("Invalid snapshot file: {}".format(snap)) - continue - self._snapshots[snapshot.id] = snapshot - + self._load_snapshot_config() # Create the project on demand on the compute node self._project_created_on_compute = set() @@ -782,6 +770,59 @@ class Project: except KeyError: raise aiohttp.web.HTTPNotFound(text="Snapshot ID {} doesn't exist".format(snapshot_id)) + def _load_snapshot_config(self): + + snapshot_dir = os.path.join(self.path, "snapshots") + self._snapshot_conf_path = os.path.join(snapshot_dir, self._snapshots_config_file) + self._snapshot_conf = [] + if os.path.isfile(self._snapshot_conf_path): + try: + with open(self._snapshot_conf_path, encoding="utf-8") as f: + self._snapshot_conf = json.load(f) + except (OSError, UnicodeDecodeError, ValueError) as e: + raise aiohttp.web.HTTPConflict(text="Could not read snapshot config {}: {}".format(self._snapshot_conf_path, str(e))) + + # Load all legacy snapshots (.gns3project files) to create an initial snapshot config if it doesn't exist + if os.path.exists(snapshot_dir) and not self._snapshot_conf: + for snap in os.listdir(snapshot_dir): + if snap.endswith(".gns3project"): + try: + snapshot = Snapshot(self, filename=snap) + except ValueError: + log.error("Invalid snapshot file: {}".format(snap)) + continue + self._snapshots[snapshot.id] = snapshot + else: + # Create the Snapshot instances from the snapshot config file + for snapshot_entry in self._snapshot_conf: + try: + 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_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)) + continue + + self._save_snapshot_config() + + def _save_snapshot_config(self): + + if not self._snapshots: + return + + self._snapshot_conf = [] + for snapshot in self._snapshots.values(): + self._snapshot_conf.append(snapshot.__json__()) + try: + with open(self._snapshot_conf_path, 'w+') as f: + json.dump(self._snapshot_conf, f, indent=4) + except OSError as e: + log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e)) + @open_required async def snapshot(self, name): """ @@ -795,12 +836,14 @@ class Project: snapshot = Snapshot(self, name=name) await snapshot.create() self._snapshots[snapshot.id] = snapshot + self._save_snapshot_config() return snapshot @open_required async def delete_snapshot(self, snapshot_id): snapshot = self.get_snapshot(snapshot_id) del self._snapshots[snapshot.id] + self._save_snapshot_config() os.remove(snapshot.path) @locking @@ -891,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") @@ -912,7 +955,7 @@ class Project: Load topology elements """ - if self._closing is True: + if self._closing: raise aiohttp.web.HTTPConflict(text="Project is closing, please try again in a few seconds...") if self._status == "opened": @@ -1083,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") @@ -1092,11 +1142,20 @@ class Project: async for chunk in zstream: await f.write(chunk) - # import the temporary project + new_project_id = str(uuid.uuid4()) + # import the duplicated 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, + reset_mac_addresses=reset_mac_addresses, + 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))) @@ -1105,6 +1164,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 duplicated 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_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)) + 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 @@ -1203,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 @@ -1255,72 +1372,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 diff --git a/gns3server/controller/snapshot.py b/gns3server/controller/snapshot.py index 4a45dacab..8bd44e94c 100644 --- a/gns3server/controller/snapshot.py +++ b/gns3server/controller/snapshot.py @@ -35,30 +35,47 @@ import logging log = logging.getLogger(__name__) -# The string use to extract the date from the filename -FILENAME_TIME_FORMAT = "%d%m%y_%H%M%S" +# Used to extract the date and time from the filename +FILENAME_DATETIME_FORMAT = "%d%m%y_%H%M%S" +# Used to create a description of the snapshot with a human-readable date and time +DESCRIPTION_DATETIME_FORMAT = "%Y-%m-%d at %H:%M:%S" class Snapshot: """ A snapshot object """ - def __init__(self, project, name=None, filename=None): + def __init__(self, project, snapshot_id=None, name=None, filename=None, created_at=None, description=None): assert filename or name, "You need to pass a name or a filename" - self._id = str(uuid.uuid4()) # We don't need to keep id between project loading because they are use only as key for operation like delete, update.. but have no impact on disk + if snapshot_id: + self._id = snapshot_id + else: + self._id = str(uuid.uuid4()) + self._project = project if name: self._name = name - self._created_at = datetime.now(timezone.utc).timestamp() - filename = self._name + "_" + datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(FILENAME_TIME_FORMAT) + ".gns3project" + + if created_at: + self._created_at = created_at + else: + self._created_at = int(datetime.now(timezone.utc).timestamp()) + if not filename: + filename = self._name + ".gns3snapshot" else: self._name = filename.rsplit("_", 2)[0] datestring = filename.replace(self._name + "_", "").split(".")[0] - self._created_at = datetime.strptime(datestring, FILENAME_TIME_FORMAT).replace(tzinfo=timezone.utc).timestamp() + self._created_at = int(datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()) + if not description: + date = datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(DESCRIPTION_DATETIME_FORMAT) + description = "Snapshot '{}' taken on {}".format(self._name, date) + + self._description = description + self._filename = filename self._path = os.path.join(project.path, "snapshots", filename) @property @@ -69,13 +86,17 @@ class Snapshot: def name(self): return self._name + @property + def description(self): + return self._description + @property def path(self): return self._path @property def created_at(self): - return int(self._created_at) + return self._created_at async def create(self): """ @@ -114,14 +135,22 @@ 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): await wait_run_in_executor(shutil.rmtree, project_files_path) with open(self._path, "rb") as f: - project = await import_project(self._project.controller, self._project.id, f, location=self._project.path, - auto_start=self._project.auto_start, auto_open=self._project.auto_open, - auto_close=self._project.auto_close) + project = await import_project( + self._project.controller, + self._project.id, + f, + location=self._project.path, + 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() @@ -132,6 +161,8 @@ class Snapshot: return { "snapshot_id": self._id, "name": self._name, - "created_at": int(self._created_at), + "created_at": self._created_at, + "description": self._description, + "filename": self._filename, "project_id": self._project.id } diff --git a/gns3server/schemas/snapshot.py b/gns3server/schemas/snapshot.py index 1c306cd2e..ddc241171 100644 --- a/gns3server/schemas/snapshot.py +++ b/gns3server/schemas/snapshot.py @@ -25,6 +25,10 @@ SNAPSHOT_CREATE_SCHEMA = { "description": "Snapshot name", "minLength": 1 }, + "description": { + "description": "Snapshot description", + "minLength": 1 + }, }, "additionalProperties": False, "required": ["name"] @@ -50,7 +54,17 @@ SNAPSHOT_OBJECT_SCHEMA = { "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" }, "name": { - "description": "Project name", + "description": "Snapshot name", + "type": "string", + "minLength": 1 + }, + "filename": { + "description": "Snapshot filename", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Snapshot description", "type": "string", "minLength": 1 }, @@ -60,5 +74,5 @@ SNAPSHOT_OBJECT_SCHEMA = { } }, "additionalProperties": False, - "required": ["snapshot_id", "name", "created_at", "project_id"] + "required": ["snapshot_id", "name", "filename", "created_at", "description", "project_id"] } diff --git a/gns3server/web/route.py b/gns3server/web/route.py index 4a7103a3c..b77095d8e 100644 --- a/gns3server/web/route.py +++ b/gns3server/web/route.py @@ -126,7 +126,7 @@ class Route(object): response.set_status(401) response.headers["WWW-Authenticate"] = 'Basic realm="GNS3 server"' # Force close the keep alive. Work around a Qt issue where Qt timeout instead of handling the 401 - # this happen only for the first query send by the client. + # this happens only for the first query send by the client. response.force_close() return response diff --git a/tests/controller/test_export_project.py b/tests/controller/test_export_project.py index de6335a4c..d95936769 100644 --- a/tests/controller/test_export_project.py +++ b/tests/controller/test_export_project.py @@ -68,7 +68,6 @@ def test_exportable_files(): assert not _is_exportable("project-files/tmp") assert not _is_exportable("project-files/test_log.txt") assert not _is_exportable("project-files/test.log") - assert not _is_exportable("test/snapshots") assert not _is_exportable("test/project-files/snapshots") assert not _is_exportable("test/project-files/snapshots/test.gns3p") diff --git a/tests/controller/test_snapshot.py b/tests/controller/test_snapshot.py index 9eaccb7bf..27299c7d9 100644 --- a/tests/controller/test_snapshot.py +++ b/tests/controller/test_snapshot.py @@ -16,22 +16,16 @@ # along with this program. If not, see . import os -import pytest -from unittest.mock import patch, MagicMock +from uuid import uuid4 -from gns3server.controller.project import Project +import pytest +from unittest import mock +from unittest.mock import patch, MagicMock from gns3server.controller.snapshot import Snapshot from tests.utils import AsyncioMagicMock -# @pytest.fixture -# def project(controller): -# project = Project(controller=controller, name="Test") -# controller._projects[project.id] = project -# return project - - def test_snapshot_name(project): """ Test create a snapshot object with a name @@ -40,12 +34,7 @@ def test_snapshot_name(project): snapshot = Snapshot(project, name="test1") assert snapshot.name == "test1" assert snapshot._created_at > 0 - assert snapshot.path.startswith(os.path.join(project.path, "snapshots", "test1_")) - assert snapshot.path.endswith(".gns3project") - - # Check if UTC conversion doesn't corrupt the path - snap2 = Snapshot(project, filename=os.path.basename(snapshot.path)) - assert snap2.path == snapshot.path + assert snapshot.path == os.path.join(project.path, "snapshots", "test1.gns3snapshot") def test_snapshot_filename(project): @@ -53,22 +42,44 @@ def test_snapshot_filename(project): Test create a snapshot object with a filename """ + # legacy snapshot snapshot = Snapshot(project, filename="test1_260716_100439.gns3project") assert snapshot.name == "test1" - assert snapshot._created_at == 1469527479.0 + assert snapshot._created_at == 1469527479 assert snapshot.path == os.path.join(project.path, "snapshots", "test1_260716_100439.gns3project") + # new style snapshot + snapshot_id = str(uuid4()) + snapshot = Snapshot(project, snapshot_id=snapshot_id, name="test2", created_at=1469527479) + assert snapshot.id == snapshot_id + assert snapshot.name == "test2" + assert snapshot.path == os.path.join(project.path, "snapshots", "test2.gns3snapshot") + assert snapshot._created_at == 1469527479 + def test_json(project): + # legacy snapshot snapshot = Snapshot(project, filename="snapshot_test_260716_100439.gns3project") assert snapshot.__json__() == { "snapshot_id": snapshot._id, "name": "snapshot_test", "project_id": project.id, + "description": "Snapshot 'snapshot_test' taken on 2016-07-26 at 10:04:39", + "filename": "snapshot_test_260716_100439.gns3project", "created_at": 1469527479 } + # new style snapshot + snapshot = Snapshot(project, name="snapshot_test2") + assert snapshot.__json__() == { + "snapshot_id": snapshot._id, + "name": "snapshot_test2", + "project_id": project.id, + "filename": "snapshot_test2.gns3snapshot", + "description": mock.ANY, + "created_at": mock.ANY + } def test_invalid_snapshot_filename(project): @@ -85,7 +96,8 @@ async def test_restore(project, controller): response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - await project.add_node(compute, "test1", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) + node1_id = str(uuid4()) + await project.add_node(compute, "test1", node1_id, node_type="vpcs", properties={"startup_config": "test.cfg"}) snapshot = await project.snapshot(name="test") # We add a node after the snapshots @@ -103,8 +115,11 @@ async def test_restore(project, controller): with patch("gns3server.config.Config.get_section_config", return_value={"local": True}): await snapshot.restore() + # make sure the original node IDs are restored + assert list(project.nodes.keys())[0] == node1_id + assert "snapshot.restored" in [c[0][0] for c in controller.notification.project_emit.call_args_list] - # project.closed notification should not be send when restoring snapshots + # project.closed notification should not be sent when restoring snapshots assert "project.closed" not in [c[0][0] for c in controller.notification.project_emit.call_args_list] project = controller.get_project(project.id) diff --git a/tests/handlers/api/controller/test_project.py b/tests/handlers/api/controller/test_project.py index 74f247598..285e26187 100644 --- a/tests/handlers/api/controller/test_project.py +++ b/tests/handlers/api/controller/test_project.py @@ -23,6 +23,7 @@ import json from unittest.mock import patch, MagicMock from tests.utils import asyncio_patch +from gns3server.controller.node import Node @pytest.fixture @@ -34,6 +35,14 @@ async def project(controller_api, controller): return controller.get_project(u) +@pytest.fixture +def node(project, compute): + + node = Node(project, compute, "test", node_type="vpcs") + project._nodes[node.id] = node + return node + + async def test_create_project_with_path(controller_api, tmpdir): response = await controller_api.post("/projects", {"name": "test", "path": str(tmpdir), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"}) @@ -295,7 +304,7 @@ async def test_export_without_images(controller_api, tmpdir, project): with myzip.open("a") as myfile: content = myfile.read() assert content == b"hello" - # Image should not exported + # Images should not be exported with pytest.raises(KeyError): myzip.getinfo("images/IOS/test.image") @@ -356,8 +365,14 @@ async def test_import(controller_api, tmpdir, controller): assert content == "hello" -async def test_duplicate(controller_api, project): +async def test_duplicate(controller_api, controller, project, node): - response = await controller_api.post("/projects/{project_id}/duplicate".format(project_id=project.id), {"name": "hello"}) + response = await controller_api.post("/projects/{project_id}/duplicate".format(project_id=project.id), {"name": "duplicated_project"}) assert response.status == 201 - assert response.json["name"] == "hello" + assert response.json["name"] == "duplicated_project" + duplicated_project_id = response.json["project_id"] + assert duplicated_project_id != project.id # a new project_id should have been generated + duplicated_project = controller.get_project(duplicated_project_id) + with open(os.path.join(duplicated_project.path, "duplicated_project.gns3")) as f: + duplicated_topology = json.load(f) + assert duplicated_topology["topology"]["nodes"][0]["node_id"] != node.id # a new node_id should have been generated diff --git a/tests/handlers/api/controller/test_snapshot.py b/tests/handlers/api/controller/test_snapshot.py index faed67df1..e136331a8 100644 --- a/tests/handlers/api/controller/test_snapshot.py +++ b/tests/handlers/api/controller/test_snapshot.py @@ -57,10 +57,11 @@ async def test_restore_snapshot(controller_api, project, snapshot): response = await controller_api.post("/projects/{}/snapshots/{}/restore".format(project.id, snapshot.id)) assert response.status == 201 assert response.json["name"] == project.name + assert response.json["project_id"] == project.id async def test_create_snapshot(controller_api, project): response = await controller_api.post("/projects/{}/snapshots".format(project.id), {"name": "snap1"}) assert response.status == 201 - assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 1 + assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2