Last adjustments for import/export

This commit is contained in:
grossmj 2026-04-10 18:58:21 +08:00
parent 6f7730c455
commit 2670c68467
No known key found for this signature in database
GPG Key ID: 1E7DD6DBB53FF3D7
5 changed files with 83 additions and 40 deletions

View File

@ -32,7 +32,16 @@ log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 8 # 8KB
async def export_project(zstream, project, temporary_dir, include_images=False, include_snapshots=False, keep_compute_ids=False, allow_all_nodes=False, reset_mac_addresses=False):
async def export_project(
zstream,
project,
temporary_dir,
include_images=False,
include_snapshots=False,
keep_compute_ids=False,
allow_all_nodes=False,
reset_mac_addresses=False
):
"""
Export a project to a zip file.
@ -46,7 +55,7 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
:param include_snapshots: save snapshots to the zip file
:param keep_compute_ids: If false replace all compute IDs y local (standard behavior for .gns3project to make it portable)
:param allow_all_nodes: Allow all nodes type to be included in the zip even if not portable
:param reset_mac_addresses: Reset MAC addresses for each node.
:param reset_mac_addresses: Reset MAC addresses for each node
"""
# To avoid issue with data not saved we disallow the export of a running project
@ -62,7 +71,16 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
# First we process the .gns3 in order to be sure we don't have an error
for file in os.listdir(project._path):
if file.endswith(".gns3"):
await _patch_project_file(project, os.path.join(project._path, file), zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses)
await _patch_project_file(
project,
os.path.join(project._path, file),
zstream,
include_images,
keep_compute_ids,
allow_all_nodes,
temporary_dir,
reset_mac_addresses
)
# Export the local files
for root, dirs, files in os.walk(project._path, topdown=True, followlinks=False):
@ -171,7 +189,16 @@ def _is_exportable(path, include_snapshots=False):
return True
async def _patch_project_file(project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses):
async def _patch_project_file(
project,
path,
zstream,
include_images,
keep_compute_ids,
allow_all_nodes,
temporary_dir,
reset_mac_addresses
):
"""
Patch a project file (.gns3) to export a project.
The .gns3 file is renamed to project.gns3
@ -244,7 +271,7 @@ async def _patch_project_file(project, path, zstream, include_images, keep_compu
for compute_id, image_type, image in remote_images:
await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir)
zstream.writestr("project.gns3", json.dumps(topology).encode())
zstream.writestr("project.gns3", json.dumps(topology, indent=4, sort_keys=True).encode())
return images

View File

@ -45,12 +45,11 @@ async def import_project(
stream,
location=None,
name=None,
reset_mac_addresses=False,
keep_compute_ids=False,
restoring_snapshot=False,
project_name=None,
auto_start=False,
auto_open=False,
auto_close=True
auto_close=True,
):
"""
Import a project contain in a zip file
@ -62,8 +61,8 @@ async def import_project(
:param stream: A io.BytesIO of the zipfile
:param location: Directory for the project if None put in the default directory
:param name: Wanted project name, generate one from the .gns3 if None
:param reset_mac_addresses: Reset MAC addresses for each node
:param keep_compute_ids: keep compute IDs unchanged
:param restoring_snapshot: True if the project is imported as part of a snapshot restore, False otherwise
:param project_name: Original project name when restoring a snapshot
:returns: Project
@ -82,12 +81,18 @@ async def import_project(
try:
topology = json.loads(project_file)
if not project_name:
# We import the project on top of an existing project (snapshots)
if topology["project_id"] == project_id:
project_name = topology["name"]
log.info("Restoring snapshot for project '{}', snapshot name: '{}'".format(project_id, project_name))
restoring_snapshot = True
else:
# If the project name is already used we generate a new one
if name:
project_name = controller.get_free_project_name(name)
else:
project_name = controller.get_free_project_name(topology["name"])
restoring_snapshot = False
except (ValueError, KeyError):
raise aiohttp.web.HTTPConflict(text="Cannot import project, the project.gns3 file is corrupted")
@ -116,8 +121,8 @@ async def import_project(
topology["auto_close"] = auto_close
if not restoring_snapshot:
# Do not re-generate IDs if we are restoring a snapshot because they should be the same as the main project
regenerate_ids(topology, path, reset_mac_addresses=True)
# Do not re-generate IDs if we are restoring a snapshot because they should be the same in a project
regenerate_topology_ids(topology, path, reset_mac_addresses=reset_mac_addresses)
# Modify the compute id of the node depending on compute capacity
if not keep_compute_ids:
@ -129,14 +134,6 @@ async def import_project(
node["compute_id"] = "vm"
else:
# Round-robin through available compute resources.
# computes = []
# for compute_id in controller.computes:
# compute = controller.get_compute(compute_id)
# # only use the local compute or any connected compute
# if compute_id == "local" or compute.connected:
# computes.append(compute_id)
# else:
# log.warning(compute.name, "is not connected!")
compute_nodes = itertools.cycle(controller.computes)
for node in topology["topology"]["nodes"]:
node["compute_id"] = next(compute_nodes)
@ -165,7 +162,7 @@ async def import_project(
snapshots_path = os.path.join(path, "snapshots")
if not restoring_snapshot and os.path.exists(snapshots_path):
await update_snapshots(snapshots_path, path, project_name, project_id)
await update_snapshots(snapshots_path, path, project_name, project_id, reset_mac_addresses=reset_mac_addresses)
project = await controller.load_project(dot_gns3_path, load=False)
return project
@ -189,9 +186,10 @@ def _create_symbolic_links(zip_file, path):
except OSError as e:
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
def regenerate_ids(topology, new_project_path, reset_mac_addresses=False):
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
"""
Regenerate IDs in the topology and move the files of the nodes to match the new IDs
Regenerate IDs in the topology and move the files of the nodes to match the new IDs.
This is necessary because IDs must be unique across projects.
:param topology: topology content
:param new_project_path: new project path
@ -289,7 +287,7 @@ async def _import_images(controller, images_path):
await wait_run_in_executor(shutil.move, path, dst)
async def update_snapshots(snapshots_dir, project_path, project_name, project_id):
async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True):
"""
Load the snapshots and update their project name and project ID to be the same as the main project.
Regenerate all the node, link and drawing IDs
@ -319,7 +317,7 @@ async def update_snapshots(snapshots_dir, project_path, project_name, project_id
topology = json.load(f)
topology["name"] = project_name
topology["project_id"] = project_id
regenerate_ids(topology, project_path, reset_mac_addresses=False)
regenerate_topology_ids(topology, project_path, reset_mac_addresses)
with open(topology_file_path, "w+", encoding="utf-8") as f:
json.dump(topology, f, indent=4, sort_keys=True)
except OSError as e:

View File

@ -382,6 +382,7 @@ class Node:
await self.parse_node_response(response.json)
return True
trial += 1
return False
async def update(self, **kwargs):
"""

View File

@ -45,7 +45,7 @@ from ..utils.asyncio import locking
from ..utils.asyncio import aiozipstream
from ..utils.asyncio import wait_run_in_executor
from .export_project import export_project
from .import_project import import_project, update_snapshots, regenerate_ids
from .import_project import import_project, update_snapshots, regenerate_topology_ids
import logging
log = logging.getLogger(__name__)
@ -819,7 +819,7 @@ class Project:
self._snapshot_conf.append(snapshot.__json__())
try:
with open(self._snapshot_conf_path, 'w+') as f:
json.dump(self._snapshot_conf, f, indent=4, sort_keys=True)
json.dump(self._snapshot_conf, f, indent=4)
except OSError as e:
log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e))
@ -1126,7 +1126,14 @@ class Project:
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
# Do not compress the exported project when duplicating
with aiozipstream.ZipFile(compression=zipfile.ZIP_STORED) as zstream:
await export_project(zstream, self, tmpdir, keep_compute_ids=True, allow_all_nodes=True, reset_mac_addresses=reset_mac_addresses)
await export_project(
zstream,
self,
tmpdir,
keep_compute_ids=True,
include_snapshots=True,
allow_all_nodes=True
)
# export the project to a temporary location
project_path = os.path.join(tmpdir, "project.gns3p")
@ -1136,9 +1143,17 @@ class Project:
await f.write(chunk)
new_project_id = str(uuid.uuid4())
# import the temporary project
# import the duplicated project
with open(project_path, "rb") as f:
project = await import_project(self._controller, new_project_id, f, location=location, name=name, keep_compute_ids=True)
project = await import_project(
self._controller,
new_project_id,
f,
location=location,
name=name,
reset_mac_addresses=reset_mac_addresses,
keep_compute_ids=True
)
log.info("Project '{}': duplicated in {:.4f} seconds".format(project.name, time.time() - begin))
except (ValueError, OSError, UnicodeEncodeError) as e:
@ -1157,7 +1172,7 @@ class Project:
:param name: Name of the new project. A new one will be generated in case of conflicts
:param location: Parent directory of the new project
:param reset_mac_addresses: Reset MAC addresses for the new project
:param reset_mac_addresses: Reset MAC addresses for the duplicated project
"""
# remote replication is not supported with remote computes
@ -1187,7 +1202,7 @@ class Project:
topology["auto_close"] = False
# regenerate IDs for the duplicated project
regenerate_ids(topology, new_project_path, reset_mac_addresses)
regenerate_topology_ids(topology, new_project_path, reset_mac_addresses)
# dump the updated .gns3 project file
dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name))
@ -1303,11 +1318,13 @@ class Project:
data['z'] = z
data['locked'] = False # duplicated node must not be locked
new_node_uuid = str(uuid.uuid4())
new_node = await self.add_node(node.compute,
node.name,
new_node_uuid,
node_type=node_type,
**data)
new_node = await self.add_node(
node.compute,
node.name,
new_node_uuid,
node_type=node_type,
**data
)
try:
await node.post("/duplicate", timeout=None, data={
"destination_node_id": new_node_uuid

View File

@ -135,6 +135,7 @@ class Snapshot:
await self._project.close(ignore_notification=True)
try:
begin = time.time()
# delete the current project files
project_files_path = os.path.join(self._project.path, "project-files")
if os.path.exists(project_files_path):
@ -145,12 +146,11 @@ class Snapshot:
self._project.id,
f,
location=self._project.path,
project_name=self._project.name,
restoring_snapshot=True,
auto_start=self._project.auto_start,
auto_open=self._project.auto_open,
auto_close=self._project.auto_close
)
log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin))
except (OSError, PermissionError) as e:
raise aiohttp.web.HTTPConflict(text=str(e))
await project.open()
@ -161,8 +161,8 @@ class Snapshot:
return {
"snapshot_id": self._id,
"name": self._name,
"description": self._description,
"created_at": self._created_at,
"description": self._description,
"filename": self._filename,
"project_id": self._project.id
}