Skip to content

Commit 24bfc20

Browse files
committed
Symbolic links support for project export/import
1 parent cb46c0f commit 24bfc20

File tree

6 files changed

+106
-26
lines changed

6 files changed

+106
-26
lines changed

gns3server/controller/export_project.py

+10-13
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,15 @@ async def export_project(zstream, project, temporary_dir, include_images=False,
7070
files = [f for f in files if _is_exportable(os.path.join(root, f), include_snapshots)]
7171
for file in files:
7272
path = os.path.join(root, file)
73-
# check if we can export the file
74-
try:
75-
open(path).close()
76-
except OSError as e:
77-
msg = "Could not export file {}: {}".format(path, e)
78-
log.warning(msg)
79-
project.emit_notification("log.warning", {"message": msg})
80-
continue
73+
if not os.path.islink(path):
74+
try:
75+
# check if we can export the file
76+
open(path).close()
77+
except OSError as e:
78+
msg = "Could not export file {}: {}".format(path, e)
79+
log.warning(msg)
80+
project.emit_notification("log.warning", {"message": msg})
81+
continue
8182
# ignore the .gns3 file
8283
if file.endswith(".gns3"):
8384
continue
@@ -128,7 +129,7 @@ def _patch_mtime(path):
128129
if sys.platform.startswith("win"):
129130
# only UNIX type platforms
130131
return
131-
st = os.stat(path)
132+
st = os.stat(path, follow_symlinks=False)
132133
file_date = datetime.fromtimestamp(st.st_mtime)
133134
if file_date.year < 1980:
134135
new_mtime = file_date.replace(year=1980).timestamp()
@@ -144,10 +145,6 @@ def _is_exportable(path, include_snapshots=False):
144145
if include_snapshots is False and path.endswith("snapshots"):
145146
return False
146147

147-
# do not export symlinks
148-
if os.path.islink(path):
149-
return False
150-
151148
# do not export directories of snapshots
152149
if include_snapshots is False and "{sep}snapshots{sep}".format(sep=os.path.sep) in path:
153150
return False

gns3server/controller/import_project.py

+21
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import os
1919
import sys
20+
import stat
2021
import json
2122
import uuid
2223
import shutil
@@ -93,6 +94,7 @@ async def import_project(controller, project_id, stream, location=None, name=Non
9394
try:
9495
with zipfile.ZipFile(stream) as zip_file:
9596
await wait_run_in_executor(zip_file.extractall, path)
97+
_create_symbolic_links(zip_file, path)
9698
except zipfile.BadZipFile:
9799
raise aiohttp.web.HTTPConflict(text="Cannot extract files from GNS3 project (invalid zip)")
98100

@@ -174,6 +176,24 @@ async def import_project(controller, project_id, stream, location=None, name=Non
174176
project = await controller.load_project(dot_gns3_path, load=False)
175177
return project
176178

179+
def _create_symbolic_links(zip_file, path):
180+
"""
181+
Manually create symbolic links (if any) because ZipFile does not support it.
182+
183+
:param zip_file: ZipFile instance
184+
:param path: project location
185+
"""
186+
187+
for zip_info in zip_file.infolist():
188+
if stat.S_ISLNK(zip_info.external_attr >> 16):
189+
symlink_target = zip_file.read(zip_info.filename).decode()
190+
symlink_path = os.path.join(path, zip_info.filename)
191+
try:
192+
# remove the regular file and replace it by a symbolic link
193+
os.remove(symlink_path)
194+
os.symlink(symlink_target, symlink_path)
195+
except OSError as e:
196+
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
177197

178198
def _move_node_file(path, old_id, new_id):
179199
"""
@@ -257,6 +277,7 @@ async def _import_snapshots(snapshots_path, project_name, project_id):
257277
with open(snapshot_path, "rb") as f:
258278
with zipfile.ZipFile(f) as zip_file:
259279
await wait_run_in_executor(zip_file.extractall, tmpdir)
280+
_create_symbolic_links(zip_file, tmpdir)
260281
except OSError as e:
261282
raise aiohttp.web.HTTPConflict(text="Cannot open snapshot '{}': {}".format(os.path.basename(snapshot), e))
262283
except zipfile.BadZipFile:

gns3server/controller/project.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1277,7 +1277,7 @@ async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=
12771277
new_project_id = str(uuid.uuid4())
12781278
new_project_path = p_work.joinpath(new_project_id)
12791279
# copy dir
1280-
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix())
1280+
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True)
12811281
log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0))
12821282
topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes())
12831283
project_name = name or topology["name"]

gns3server/utils/asyncio/aiozipstream.py

+15-11
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,17 @@ def comment(self, comment):
161161
self._comment = comment
162162
self._didModify = True
163163

164-
async def data_generator(self, path):
165-
166-
async with aiofiles.open(path, "rb") as f:
167-
while True:
168-
part = await f.read(self._chunksize)
169-
if not part:
170-
break
171-
yield part
164+
async def data_generator(self, path, islink=False):
165+
166+
if islink:
167+
yield os.readlink(path).encode()
168+
else:
169+
async with aiofiles.open(path, "rb") as f:
170+
while True:
171+
part = await f.read(self._chunksize)
172+
if not part:
173+
break
174+
yield part
172175
return
173176

174177
async def _run_in_executor(self, task, *args, **kwargs):
@@ -224,12 +227,13 @@ async def _write(self, filename=None, iterable=None, arcname=None, compress_type
224227
raise ValueError("either (exclusively) filename or iterable shall be not None")
225228

226229
if filename:
227-
st = os.stat(filename)
230+
st = os.stat(filename, follow_symlinks=False)
228231
isdir = stat.S_ISDIR(st.st_mode)
232+
islink = stat.S_ISLNK(st.st_mode)
229233
mtime = time.localtime(st.st_mtime)
230234
date_time = mtime[0:6]
231235
else:
232-
st, isdir, date_time = None, False, time.localtime()[0:6]
236+
st, isdir, islink, date_time = None, False, False, time.localtime()[0:6]
233237
# Create ZipInfo instance to store file information
234238
if arcname is None:
235239
arcname = filename
@@ -282,7 +286,7 @@ async def _write(self, filename=None, iterable=None, arcname=None, compress_type
282286

283287
file_size = 0
284288
if filename:
285-
async for buf in self.data_generator(filename):
289+
async for buf in self.data_generator(filename, islink):
286290
file_size = file_size + len(buf)
287291
CRC = zipfile.crc32(buf, CRC) & 0xffffffff
288292
if cmpr:

tests/controller/test_export_project.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import pytest
2222
import aiohttp
2323
import zipfile
24+
import stat
2425

2526
from pathlib import Path
2627
from unittest.mock import patch
@@ -116,6 +117,8 @@ async def test_export(tmpdir, project):
116117
with open(os.path.join(path, "project-files", "snapshots", "test"), 'w+') as f:
117118
f.write("WORLD")
118119

120+
os.symlink("/tmp/anywhere", os.path.join(path, "vm-1", "dynamips", "symlink"))
121+
119122
with aiozipstream.ZipFile() as z:
120123
with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),):
121124
await export_project(z, project, str(tmpdir), include_images=False)
@@ -131,9 +134,12 @@ async def test_export(tmpdir, project):
131134
assert 'vm-1/dynamips/empty-dir/' in myzip.namelist()
132135
assert 'project-files/snapshots/test' not in myzip.namelist()
133136
assert 'vm-1/dynamips/test_log.txt' not in myzip.namelist()
134-
135137
assert 'images/IOS/test.image' not in myzip.namelist()
136138

139+
assert 'vm-1/dynamips/symlink' in myzip.namelist()
140+
zip_info = myzip.getinfo('vm-1/dynamips/symlink')
141+
assert stat.S_ISLNK(zip_info.external_attr >> 16)
142+
137143
with myzip.open("project.gns3") as myfile:
138144
topo = json.loads(myfile.read().decode())["topology"]
139145
assert topo["nodes"][0]["compute_id"] == "local" # All node should have compute_id local after export

tests/controller/test_import_project.py

+52
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
import zipfile
2222

2323
from tests.utils import asyncio_patch, AsyncioMagicMock
24+
from unittest.mock import patch, MagicMock
2425

26+
from gns3server.utils.asyncio import aiozipstream
27+
from gns3server.controller.project import Project
28+
from gns3server.controller.export_project import export_project
2529
from gns3server.controller.import_project import import_project, _move_files_to_compute
2630
from gns3server.version import __version__
2731

@@ -106,6 +110,54 @@ async def test_import_project_override(tmpdir, controller):
106110
assert project.name == "test"
107111

108112

113+
async def write_file(path, z):
114+
115+
with open(path, 'wb') as f:
116+
async for chunk in z:
117+
f.write(chunk)
118+
119+
120+
async def test_import_project_containing_symlink(tmpdir, controller):
121+
122+
project = Project(controller=controller, name="test")
123+
project.dump = MagicMock()
124+
path = project.path
125+
126+
project_id = str(uuid.uuid4())
127+
topology = {
128+
"project_id": str(uuid.uuid4()),
129+
"name": "test",
130+
"auto_open": True,
131+
"auto_start": True,
132+
"topology": {
133+
},
134+
"version": "2.0.0"
135+
}
136+
137+
with open(os.path.join(path, "project.gns3"), 'w+') as f:
138+
json.dump(topology, f)
139+
140+
os.makedirs(os.path.join(path, "vm1", "dynamips"))
141+
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
142+
symlink_target = "/tmp/anywhere"
143+
os.symlink(symlink_target, symlink_path)
144+
145+
zip_path = str(tmpdir / "project.zip")
146+
with aiozipstream.ZipFile() as z:
147+
with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),):
148+
await export_project(z, project, str(tmpdir), include_images=False)
149+
await write_file(zip_path, z)
150+
151+
with open(zip_path, "rb") as f:
152+
project = await import_project(controller, project_id, f)
153+
154+
assert project.name == "test"
155+
assert project.id == project_id
156+
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
157+
assert os.path.islink(symlink_path)
158+
assert os.readlink(symlink_path) == symlink_target
159+
160+
109161
async def test_import_upgrade(tmpdir, controller):
110162
"""
111163
Topology made for previous GNS3 version are upgraded during the process

0 commit comments

Comments
 (0)