diff --git a/gns3server/controller/export_project.py b/gns3server/controller/export_project.py
index 9db433818..7f62be18c 100644
--- a/gns3server/controller/export_project.py
+++ b/gns3server/controller/export_project.py
@@ -200,13 +200,16 @@ async def _patch_project_file(project, path, zstream, include_images, keep_compu
if not keep_compute_ids:
node["compute_id"] = "local" # To make project portable all node by default run on local
- if "properties" in node and node["node_type"] != "docker":
+ if "properties" in node:
for prop, value in node["properties"].items():
# reset the MAC address
if reset_mac_addresses and prop in ("mac_addr", "mac_address"):
node["properties"][prop] = None
+ if node["node_type"] == "docker":
+ continue
+
if node["node_type"] == "iou":
if not prop == "path":
continue
diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py
index 644d9ba33..3c5c5229d 100644
--- a/gns3server/controller/project.py
+++ b/gns3server/controller/project.py
@@ -15,6 +15,7 @@
# 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
@@ -27,6 +28,7 @@
import aiofiles
import tempfile
import zipfile
+import pathlib
from uuid import UUID, uuid4
@@ -42,8 +44,9 @@
from ..utils.asyncio.pool import Pool
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
+from .import_project import import_project, _move_node_file
import logging
log = logging.getLogger(__name__)
@@ -1037,14 +1040,16 @@ async def duplicate(self, name=None, location=None, reset_mac_addresses=True):
"""
Duplicate a project
- It's the save as feature of the 1.X. It's implemented on top of the
- export / import features. It will generate a gns3p and reimport it.
- It's a little slower but we have only one implementation to maintain.
+ Implemented on top of the export / import features. It will generate a gns3p and reimport it.
+
+ NEW: fast duplication is used if possible (when there are no remote computes).
+ If not, the project is exported and reimported as explained above.
: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
"""
+
# If the project was not open we open it temporary
previous_status = self._status
if self._status == "closed":
@@ -1052,6 +1057,18 @@ async def duplicate(self, name=None, location=None, reset_mac_addresses=True):
self.dump()
assert self._status != "closed"
+
+ try:
+ proj = await self._fast_duplication(name, location, reset_mac_addresses)
+ if proj:
+ if previous_status == "closed":
+ await self.close()
+ return proj
+ else:
+ log.info("Fast duplication failed, fallback to normal duplication")
+ except Exception as e:
+ raise aiohttp.web.HTTPConflict(text="Cannot duplicate project: {}".format(str(e)))
+
try:
begin = time.time()
@@ -1237,3 +1254,70 @@ def __json__(self):
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())
+ 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())
+ 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