diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index edbf8c11..20cf8dd8 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -406,7 +406,7 @@ class Project: The file will be read chunk by chunk when you iterate on the zip. - It will ignore some files like snapshots and + It will ignore some files like snapshots and tmp :returns: ZipStream object """ @@ -443,12 +443,11 @@ class Project: z.write(path, os.path.relpath(path, self._path), compress_type=zipfile.ZIP_DEFLATED) return z - def _export_images(self, image, type, z): + def _export_images(self, image, z): """ Take a project file (.gns3) and export images to the zip :param image: Image path - :param type: Type of image :param z: Zipfile instance for the export """ from . import MODULES @@ -488,7 +487,7 @@ class Project: if prop.endswith("image"): node["properties"][prop] = os.path.basename(value) if include_images is True: - self._export_images(value, node["type"], z) + self._export_images(value, z) z.writestr("project.gns3", json.dumps(topology).encode()) def import_zip(self, stream, gns3vm=True): diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 7f3c6d4a..507dc4b3 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -20,6 +20,8 @@ import json import asyncio import aiohttp import shutil +import zipstream +import zipfile from uuid import UUID, uuid4 @@ -426,6 +428,96 @@ class Project: drawing = yield from self.add_drawing(**drawing_data) self._status = "opened" + def export(self, include_images=False): + """ + Export the project as zip. It's a ZipStream object. + The file will be read chunk by chunk when you iterate on + the zip. + + It will ignore some files like snapshots and + + :returns: ZipStream object + """ + z = zipstream.ZipFile() + for root, dirs, files in os.walk(self._path, topdown=True): + # Remove snapshots and capture + if os.path.split(root)[-1:][0] == "project-files": + dirs[:] = [d for d in dirs if d not in ("snapshots", "tmp")] + + # Ignore log files and OS noise + files = [f for f in files if not f.endswith('_log.txt') and not f.endswith('.log') and f != '.DS_Store'] + + for file in files: + path = os.path.join(root, file) + # Try open the file + try: + open(path).close() + except OSError as e: + msg = "Could not export file {}: {}".format(path, e) + log.warn(msg) + self.emit("log.warning", {"message": msg}) + continue + # We rename the .gns3 project.gns3 to avoid the task to the client to guess the file name + if file.endswith(".gns3"): + self._export_project_file(path, z, include_images) + else: + z.write(path, os.path.relpath(path, self._path), compress_type=zipfile.ZIP_DEFLATED) + return z + + def _export_project_file(self, path, z, include_images): + """ + Take a project file (.gns3) and patch it for the export + + :param path: Path of the .gns3 + """ + + # Image file that we need to include in the exported archive + images = set() + + with open(path) as f: + topology = json.load(f) + if "topology" in topology and "nodes" in topology["topology"]: + for node in topology["topology"]["nodes"]: + if "properties" in node and node["node_type"] != "Docker": + for prop, value in node["properties"].items(): + if prop.endswith("image"): + node["properties"][prop] = os.path.basename(value) + if include_images is True: + images.append(value) + + for image in images: + self._export_images(image, z) + z.writestr("project.gns3", json.dumps(topology).encode()) + + def _export_images(self, image, z): + """ + Take a project file (.gns3) and export images to the zip + + :param image: Image path + :param z: Zipfile instance for the export + """ + from ..compute import MODULES + + for module in MODULES: + try: + img_directory = module.instance().get_images_directory() + except NotImplementedError: + # Some modules don't have images + continue + + directory = os.path.split(img_directory)[-1:][0] + + if os.path.exists(image): + path = image + else: + path = os.path.join(img_directory, image) + + # FIXME: av + if os.path.exists(path): + arcname = os.path.join("images", directory, os.path.basename(image)) + z.write(path, arcname) + break + def dump(self): """ Dump topology to disk diff --git a/gns3server/handlers/api/controller/project_handler.py b/gns3server/handlers/api/controller/project_handler.py index 5e69b4f3..fffdcacc 100644 --- a/gns3server/handlers/api/controller/project_handler.py +++ b/gns3server/handlers/api/controller/project_handler.py @@ -214,3 +214,32 @@ class ProjectHandler: break ws.send_str(notification) return ws + + @Route.get( + r"/projects/{project_id}/export", + description="Export a project as a portable archive", + parameters={ + "project_id": "Project UUID", + }, + raw=True, + status_codes={ + 200: "File returned", + 404: "The project doesn't exist" + }) + def export_project(request, response): + + controller = Controller.instance() + project = controller.get_project(request.match_info["project_id"]) + + response.content_type = 'application/gns3project' + response.headers['CONTENT-DISPOSITION'] = 'attachment; filename="{}.gns3project"'.format(project.name) + response.enable_chunked_encoding() + # Very important: do not send a content length otherwise QT closes the connection (curl can consume the feed) + response.content_length = None + response.start(request) + + for data in project.export(include_images=bool(request.GET.get("include_images", "0"))): + response.write(data) + yield from response.drain() + + yield from response.write_eof() diff --git a/tests/compute/test_project.py b/tests/compute/test_project.py index 32cb0064..7dcdcfe9 100644 --- a/tests/compute/test_project.py +++ b/tests/compute/test_project.py @@ -182,10 +182,6 @@ def test_export(tmpdir): path = project.path os.makedirs(os.path.join(path, "vm-1", "dynamips")) - # The .gns3 should be renamed project.gns3 in order to simplify import - with open(os.path.join(path, "test.gns3"), 'w+') as f: - f.write("{}") - with open(os.path.join(path, "vm-1", "dynamips", "test"), 'w+') as f: f.write("HELLO") with open(os.path.join(path, "vm-1", "dynamips", "test_log.txt"), 'w+') as f: @@ -205,86 +201,10 @@ def test_export(tmpdir): content = myfile.read() assert content == b"HELLO" - assert 'test.gns3' not in myzip.namelist() - assert 'project.gns3' in myzip.namelist() assert 'project-files/snapshots/test' not in myzip.namelist() assert 'vm-1/dynamips/test_log.txt' not in myzip.namelist() -def test_export_fix_path(tmpdir): - """ - Fix absolute image path - """ - - project = Project(project_id=str(uuid.uuid4())) - path = project.path - - topology = { - "topology": { - "nodes": [ - { - "properties": { - "image": "/tmp/c3725-adventerprisek9-mz.124-25d.image" - }, - "type": "C3725" - } - ] - } - } - - with open(os.path.join(path, "test.gns3"), 'w+') as f: - json.dump(topology, f) - - z = project.export() - with open(str(tmpdir / 'zipfile.zip'), 'wb') as f: - for data in z: - f.write(data) - - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: - with myzip.open("project.gns3") as myfile: - content = myfile.read().decode() - topology = json.loads(content) - assert topology["topology"]["nodes"][0]["properties"]["image"] == "c3725-adventerprisek9-mz.124-25d.image" - - -def test_export_with_images(tmpdir): - """ - Fix absolute image path - """ - project_id = str(uuid.uuid4()) - project = Project(project_id=project_id) - path = project.path - - os.makedirs(str(tmpdir / "IOS")) - with open(str(tmpdir / "IOS" / "test.image"), "w+") as f: - f.write("AAA") - - topology = { - "topology": { - "nodes": [ - { - "properties": { - "image": "test.image" - }, - "type": "C3725" - } - ] - } - } - - with open(os.path.join(path, "test.gns3"), 'w+') as f: - json.dump(topology, f) - - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): - z = project.export(include_images=True) - with open(str(tmpdir / 'zipfile.zip'), 'wb') as f: - for data in z: - f.write(data) - - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: - myzip.getinfo("images/IOS/test.image") - - def test_export_with_vm(tmpdir): project_id = str(uuid.uuid4()) project = Project(project_id=project_id) diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 8b300486..71136e1d 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -17,8 +17,11 @@ # along with this program. If not, see . import os +import uuid +import json import pytest import aiohttp +import zipfile from unittest.mock import MagicMock from tests.utils import AsyncioMagicMock from unittest.mock import patch @@ -66,7 +69,6 @@ def test_path_exist(tmpdir): p = Project(name="Test", path=str(tmpdir / "demo")) - def test_init_path(tmpdir): p = Project(path=str(tmpdir), project_id=str(uuid4()), name="Test") @@ -314,3 +316,107 @@ def test_open_close(async_run, controller): assert project.status == "opened" async_run(project.close()) assert project.status == "closed" + + +def test_export(tmpdir, project): + path = project.path + os.makedirs(os.path.join(path, "vm-1", "dynamips")) + + # The .gns3 should be renamed project.gns3 in order to simplify import + with open(os.path.join(path, "test.gns3"), 'w+') as f: + f.write("{}") + + with open(os.path.join(path, "vm-1", "dynamips", "test"), 'w+') as f: + f.write("HELLO") + with open(os.path.join(path, "vm-1", "dynamips", "test_log.txt"), 'w+') as f: + f.write("LOG") + os.makedirs(os.path.join(path, "project-files", "snapshots")) + with open(os.path.join(path, "project-files", "snapshots", "test"), 'w+') as f: + f.write("WORLD") + + z = project.export() + + with open(str(tmpdir / 'zipfile.zip'), 'wb') as f: + for data in z: + f.write(data) + + with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with myzip.open("vm-1/dynamips/test") as myfile: + content = myfile.read() + assert content == b"HELLO" + + assert 'test.gns3' not in myzip.namelist() + assert 'project.gns3' in myzip.namelist() + assert 'project-files/snapshots/test' not in myzip.namelist() + assert 'vm-1/dynamips/test_log.txt' not in myzip.namelist() + + +def test_export_fix_path(tmpdir, project): + """ + Fix absolute image path + """ + + path = project.path + + topology = { + "topology": { + "nodes": [ + { + "properties": { + "image": "/tmp/c3725-adventerprisek9-mz.124-25d.image" + }, + "node_type": "dynamips" + } + ] + } + } + + with open(os.path.join(path, "test.gns3"), 'w+') as f: + json.dump(topology, f) + + z = project.export() + with open(str(tmpdir / 'zipfile.zip'), 'wb') as f: + for data in z: + f.write(data) + + with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with myzip.open("project.gns3") as myfile: + content = myfile.read().decode() + topology = json.loads(content) + assert topology["topology"]["nodes"][0]["properties"]["image"] == "c3725-adventerprisek9-mz.124-25d.image" + + +def test_export_with_images(tmpdir, project): + """ + Fix absolute image path + """ + path = project.path + + os.makedirs(str(tmpdir / "IOS")) + with open(str(tmpdir / "IOS" / "test.image"), "w+") as f: + f.write("AAA") + + topology = { + "topology": { + "nodes": [ + { + "properties": { + "image": "test.image" + }, + "node_type": "dynamips" + } + ] + } + } + + with open(os.path.join(path, "test.gns3"), 'w+') as f: + json.dump(topology, f) + + with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): + z = project.export(include_images=True) + with open(str(tmpdir / 'zipfile.zip'), 'wb') as f: + for data in z: + f.write(data) + + with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + myzip.getinfo("images/IOS/test.image") diff --git a/tests/handlers/api/controller/test_project.py b/tests/handlers/api/controller/test_project.py index b1e17aa0..b5ab547d 100644 --- a/tests/handlers/api/controller/test_project.py +++ b/tests/handlers/api/controller/test_project.py @@ -24,6 +24,7 @@ import os import asyncio import aiohttp import pytest +import zipfile import json @@ -153,3 +154,23 @@ def test_notification_ws(http_controller, controller, project, async_run): assert answer["action"] == "test" async_run(http_controller.close()) + + +def test_export(http_controller, tmpdir, loop, project): + + os.makedirs(project.path, exist_ok=True) + with open(os.path.join(project.path, 'a'), 'w+') as f: + f.write('hello') + + response = http_controller.get("/projects/{project_id}/export".format(project_id=project.id), raw=True) + assert response.status == 200 + assert response.headers['CONTENT-TYPE'] == 'application/gns3project' + assert response.headers['CONTENT-DISPOSITION'] == 'attachment; filename="{}.gns3project"'.format(project.name) + + with open(str(tmpdir / 'project.zip'), 'wb+') as f: + f.write(response.body) + + with zipfile.ZipFile(str(tmpdir / 'project.zip')) as myzip: + with myzip.open("a") as myfile: + content = myfile.read() + assert content == b"hello"