Update snapshots when duplicating a project

This commit is contained in:
grossmj 2026-04-09 23:18:55 +08:00
parent 274cef6cf5
commit 6f7730c455
No known key found for this signature in database
GPG Key ID: 1E7DD6DBB53FF3D7
4 changed files with 115 additions and 113 deletions

View File

@ -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))

View File

@ -117,26 +117,7 @@ async def import_project(
if not restoring_snapshot:
# Do not re-generate IDs if we are restoring a snapshot because they should be the same as the main project
# Generate a new node id
node_old_to_new = {}
for node in topology["topology"]["nodes"]:
if "node_id" in node:
node_old_to_new[node["node_id"]] = str(uuid.uuid4())
_move_node_file(path, node["node_id"], node_old_to_new[node["node_id"]])
node["node_id"] = node_old_to_new[node["node_id"]]
else:
node["node_id"] = str(uuid.uuid4())
# Update link to use new id
for link in topology["topology"]["links"]:
link["link_id"] = str(uuid.uuid4())
for node in link["nodes"]:
node["node_id"] = node_old_to_new[node["node_id"]]
# Generate new drawings id
for drawing in topology["topology"]["drawings"]:
drawing["drawing_id"] = str(uuid.uuid4())
regenerate_ids(topology, path, reset_mac_addresses=True)
# Modify the compute id of the node depending on compute capacity
if not keep_compute_ids:
@ -175,7 +156,7 @@ async def import_project(
# We change the project_id to avoid erasing the project
topology["project_id"] = project_id
with open(dot_gns3_path, "w+") as f:
json.dump(topology, f, indent=4)
json.dump(topology, f, indent=4, sort_keys=True)
os.remove(os.path.join(path, "project.gns3"))
images_path = os.path.join(path, "images")
@ -183,8 +164,8 @@ async def import_project(
await _import_images(controller, images_path)
snapshots_path = os.path.join(path, "snapshots")
if os.path.exists(snapshots_path):
await _import_snapshots(snapshots_path, project_name, project_id)
if not restoring_snapshot and os.path.exists(snapshots_path):
await update_snapshots(snapshots_path, path, project_name, project_id)
project = await controller.load_project(dot_gns3_path, load=False)
return project
@ -208,6 +189,40 @@ def _create_symbolic_links(zip_file, path):
except OSError as e:
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
def regenerate_ids(topology, new_project_path, reset_mac_addresses=False):
"""
Regenerate IDs in the topology and move the files of the nodes to match the new IDs
:param topology: topology content
:param new_project_path: new project path
:param reset_mac_addresses: reset MAC addresses
"""
# Generate new node IDs
node_old_to_new = {}
for node in topology["topology"]["nodes"]:
new_node_id = str(uuid.uuid4())
if "node_id" in node:
node_old_to_new[node["node_id"]] = new_node_id
_move_node_file(new_project_path, node["node_id"], new_node_id)
node["node_id"] = new_node_id
if reset_mac_addresses:
if "properties" in node:
for prop, value in node["properties"].items():
# reset the MAC address
if prop in ("mac_addr", "mac_address"):
node["properties"][prop] = None
# Generate new link IDs
for link in topology["topology"]["links"]:
link["link_id"] = str(uuid.uuid4())
for node in link["nodes"]:
node["node_id"] = node_old_to_new[node["node_id"]]
# Generate new drawings IDs
for drawing in topology["topology"]["drawings"]:
drawing["drawing_id"] = str(uuid.uuid4())
def _move_node_file(path, old_id, new_id):
"""
Move a file from a node when changing its id
@ -274,16 +289,17 @@ async def _import_images(controller, images_path):
await wait_run_in_executor(shutil.move, path, dst)
async def _import_snapshots(snapshots_path, project_name, project_id):
async def update_snapshots(snapshots_dir, project_path, project_name, project_id):
"""
Import the snapshots and update their project name and ID to be the same as the main project.
Load the snapshots and update their project name and project ID to be the same as the main project.
Regenerate all the node, link and drawing IDs
"""
for snapshot in os.listdir(snapshots_path):
if not snapshot.endswith(".gns3project"):
for snapshot in os.listdir(snapshots_dir):
if not (snapshot.endswith(".gns3snapshot") or snapshot.endswith(".gns3project")):
continue
snapshot_path = os.path.join(snapshots_path, snapshot)
with tempfile.TemporaryDirectory(dir=snapshots_path) as tmpdir:
snapshot_path = os.path.join(snapshots_dir, snapshot)
with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir:
# extract everything to a temporary directory
try:
@ -301,9 +317,9 @@ async def _import_snapshots(snapshots_path, project_name, project_id):
topology_file_path = os.path.join(tmpdir, "project.gns3")
with open(topology_file_path, encoding="utf-8") as f:
topology = json.load(f)
topology["name"] = project_name
topology["project_id"] = project_id
regenerate_ids(topology, project_path, reset_mac_addresses=False)
with open(topology_file_path, "w+", encoding="utf-8") as f:
json.dump(topology, f, indent=4, sort_keys=True)
except OSError as e:
@ -321,5 +337,6 @@ async def _import_snapshots(snapshots_path, project_name, project_id):
async with aiofiles.open(snapshot_path, 'wb+') as f:
async for chunk in zstream:
await f.write(chunk)
log.info("Project '{}': updated and repacked snapshot file '{}'".format(project_name, snapshot))
except OSError as e:
raise aiohttp.web.HTTPConflict(text="Cannot update snapshot '{}': the snapshot cannot be recreated: {}".format(os.path.basename(snapshot), e))

View File

@ -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', {})

View File

@ -15,7 +15,6 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import re
import os
import json
@ -46,7 +45,7 @@ from ..utils.asyncio import locking
from ..utils.asyncio import aiozipstream
from ..utils.asyncio import wait_run_in_executor
from .export_project import export_project
from .import_project import import_project, _move_node_file
from .import_project import import_project, update_snapshots, regenerate_ids
import logging
log = logging.getLogger(__name__)
@ -794,18 +793,15 @@ class Project:
continue
self._snapshots[snapshot.id] = snapshot
else:
# Create the snapshot instances from the snapshot config file
# Create the Snapshot instances from the snapshot config file
for snapshot_entry in self._snapshot_conf:
try:
name = snapshot_entry["name"]
snapshot_id = snapshot_entry["snapshot_id"]
created_at = snapshot_entry["created_at"]
filename = snapshot_entry["filename"]
path = os.path.join(snapshot_dir, filename)
path = os.path.join(snapshot_dir, snapshot_entry["filename"])
if not os.path.isfile(path):
log.warning("Snapshot file '{}' does not exist".format(path))
continue
snapshot = Snapshot(self, name=name, filename=filename, snapshot_id=snapshot_id, created_at=created_at)
snapshot_entry.pop("project_id")
snapshot = Snapshot(self, **snapshot_entry)
self._snapshots[snapshot.id] = snapshot
except KeyError:
log.error("Invalid entry in snapshot config file: {}".format(snapshot_entry))
@ -823,7 +819,7 @@ class Project:
self._snapshot_conf.append(snapshot.__json__())
try:
with open(self._snapshot_conf_path, 'w+') as f:
json.dump(self._snapshot_conf, f, indent=4)
json.dump(self._snapshot_conf, f, indent=4, sort_keys=True)
except OSError as e:
log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e))
@ -938,7 +934,7 @@ class Project:
def _get_default_project_directory(cls):
"""
Return the default location for the project directory
depending of the operating system
depending on the operating system
"""
server_config = Config.instance().get_section_config("Server")
@ -1139,11 +1135,12 @@ class Project:
async for chunk in zstream:
await f.write(chunk)
new_project_id = str(uuid.uuid4())
# import the temporary project
with open(project_path, "rb") as f:
project = await import_project(self._controller, str(uuid.uuid4()), f, location=location, name=name, keep_compute_ids=True)
project = await import_project(self._controller, new_project_id, f, location=location, name=name, keep_compute_ids=True)
log.info("Project '{}' duplicated in {:.4f} seconds".format(project.name, time.time() - begin))
log.info("Project '{}': duplicated in {:.4f} seconds".format(project.name, time.time() - begin))
except (ValueError, OSError, UnicodeEncodeError) as e:
raise aiohttp.web.HTTPConflict(text="Cannot duplicate project: {}".format(str(e)))
@ -1152,6 +1149,62 @@ class Project:
return project
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True):
"""
Fast duplication of a project.
Copy the project files directly rather than in an import-export fashion.
:param name: Name of the new project. A new one will be generated in case of conflicts
:param location: Parent directory of the new project
:param reset_mac_addresses: Reset MAC addresses for the new project
"""
# remote replication is not supported with remote computes
for compute in self.computes:
if compute.id != "local":
log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id))
return None
# work dir
p_work = pathlib.Path(location or self.path).parent.absolute()
t0 = time.time()
new_project_id = str(uuid.uuid4())
if location:
new_project_path = p_work.joinpath(location)
else:
new_project_path = p_work.joinpath(new_project_id)
# copy dir
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True)
log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0))
topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes())
project_name = name or topology["name"]
# If the project name is already used we generate a new one
project_name = self.controller.get_free_project_name(project_name)
topology["name"] = project_name
# To avoid unexpected behavior (project start without manual operations just after import)
topology["auto_start"] = False
topology["auto_open"] = False
topology["auto_close"] = False
# regenerate IDs for the duplicated project
regenerate_ids(topology, new_project_path, reset_mac_addresses)
# dump the updated .gns3 project file
dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name))
topology["project_id"] = new_project_id
with open(dot_gns3_path, "w+") as f:
json.dump(topology, f, indent=4, sort_keys=True)
# update the snapshots with new IDs
snapshots_dir = os.path.join(new_project_path, "snapshots")
if os.path.isdir(snapshots_dir):
await update_snapshots(snapshots_dir, new_project_path, project_name, new_project_id)
os.remove(new_project_path.joinpath('{}.gns3'.format(self.name)))
project = await self.controller.load_project(dot_gns3_path, load=False)
log.info("Project '{}': fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0))
return project
def is_running(self):
"""
If a node is started or paused return True
@ -1302,72 +1355,4 @@ class Project:
def __repr__(self):
return "<gns3server.controller.Project {} {}>".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