diff --git a/gns3server/modules/qemu/qcow2.py b/gns3server/modules/qemu/qcow2.py new file mode 100644 index 00000000..9f0fc462 --- /dev/null +++ b/gns3server/modules/qemu/qcow2.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# +# Copyright (C) 2016 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import asyncio +import struct + + +class Qcow2Error(Exception): + pass + + +class Qcow2: + """ + Allow to parse a Qcow2 file + """ + + def __init__(self, path): + + self.path = path + self._reload() + + def _reload(self): + # Each QCOW2 file begins with a header, in big endian format, as follows: + # + # typedef struct QCowHeader { + # uint32_t magic; + # uint32_t version; + # + # uint64_t backing_file_offset; + # uint32_t backing_file_size; + # + # uint32_t cluster_bits; + # uint64_t size; /* in bytes */ + # uint32_t crypt_method; + # + # uint32_t l1_size; + # uint64_t l1_table_offset; + # + # uint64_t refcount_table_offset; + # uint32_t refcount_table_clusters; + # + # uint32_t nb_snapshots; + # uint64_t snapshots_offset; + # } QCowHeader; + struct_format = ">IIQi" + + with open(self.path, 'rb') as f: + content = f.read(struct.calcsize(struct_format)) + + self.magic, self.version, self.backing_file_offset, self.backing_file_size = struct.unpack_from(struct_format, content) + + if self.magic != 1363560955: # The first 4 bytes contain the characters 'Q', 'F', 'I' followed by 0xfb. + raise Qcow2Error("Invalid magic for {}".format(self.path)) + + @property + def backing_file(self): + """ + When using linked clone this will return the path to the base image + + :returns: None if it's not a linked clone, the path otherwise + """ + with open(self.path, 'rb') as f: + f.seek(self.backing_file_offset) + content = f.read(self.backing_file_size) + path = content.decode() + if len(path) == 0: + return None + return path + + @asyncio.coroutine + def rebase(self, qemu_img, base_image): + """ + Rebase a linked clone in order to use the correct disk + + :param qemu_img: Path to the qemu-img binary + :param base_image: Path to the base image + """ + if not os.path.exists(base_image): + raise FileNotFoundError(base_image) + command = [qemu_img, "rebase", "-u", "-b", base_image, self.path] + process = yield from asyncio.create_subprocess_exec(*command) + retcode = yield from process.wait() + if retcode != 0: + raise Qcow2Error("Could not rebase the image") + self._reload() diff --git a/tests/modules/qemu/test_qcow2.py b/tests/modules/qemu/test_qcow2.py new file mode 100644 index 00000000..d193c625 --- /dev/null +++ b/tests/modules/qemu/test_qcow2.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# +# Copyright (C) 2016 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import pytest +import shutil +import asyncio + +from gns3server.modules.qemu.qcow2 import Qcow2,Qcow2Error + + +def qemu_img(): + """ + Return the path of qemu-img on system. + We can't use shutil.which because for safety reason we break + the PATH to avoid test interacting with real binaries + """ + paths = [ + "/usr/bin/qemu-img", + "/usr/local/bin/qemu-img" + ] + for path in paths: + if os.path.exists(path): + return path + return None + + +def test_valid_base_file(): + qcow2 = Qcow2("tests/resources/empty8G.qcow2") + assert qcow2.version == 3 + assert qcow2.backing_file is None + + +def test_valid_linked_file(): + qcow2 = Qcow2("tests/resources/linked.qcow2") + assert qcow2.version == 3 + assert qcow2.backing_file == "empty8G.qcow2" + + +def test_invalid_file(): + with pytest.raises(Qcow2Error): + Qcow2("tests/resources/nvram_iou") + + +@pytest.mark.skipif(qemu_img() is None, reason="qemu-img is not available") +def test_rebase(tmpdir, loop): + shutil.copy("tests/resources/empty8G.qcow2", str(tmpdir / "empty16G.qcow2")) + shutil.copy("tests/resources/linked.qcow2", str(tmpdir / "linked.qcow2")) + qcow2 = Qcow2(str(tmpdir / "linked.qcow2")) + assert qcow2.version == 3 + assert qcow2.backing_file == "empty8G.qcow2" + loop.run_until_complete(asyncio.async(qcow2.rebase(qemu_img(), str(tmpdir / "empty16G.qcow2")))) + assert qcow2.backing_file == str(tmpdir / "empty16G.qcow2") + diff --git a/tests/resources/empty8G.qcow2 b/tests/resources/empty8G.qcow2 new file mode 100644 index 00000000..beec669a Binary files /dev/null and b/tests/resources/empty8G.qcow2 differ diff --git a/tests/resources/linked.qcow2 b/tests/resources/linked.qcow2 new file mode 100644 index 00000000..8fad5487 Binary files /dev/null and b/tests/resources/linked.qcow2 differ