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