Skip to content
Snippets Groups Projects
image.py 20.8 KiB
Newer Older
Nikos Skalkotos's avatar
Nikos Skalkotos committed
# -*- coding: utf-8 -*-
#
# Copyright (C) 2011-2015 GRNET S.A.
Nikos Skalkotos's avatar
Nikos Skalkotos committed
# 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.
Nikos Skalkotos's avatar
Nikos Skalkotos committed
# 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.
Nikos Skalkotos's avatar
Nikos Skalkotos committed
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
Nikos Skalkotos's avatar
Nikos Skalkotos committed
"""Module hosting the Image class."""

from image_creator.util import FatalError, QemuNBD, get_command
from image_creator.gpt import GPTPartitionTable
from image_creator.os_type import os_cls

from sendfile import sendfile
import threading
# Make sure libguestfs runs qemu directly to launch an appliance.
os.environ['LIBGUESTFS_BACKEND'] = 'direct'
import guestfs  # noqa


class Image(object):
    """The instances of this class can create images out of block devices."""

    def __init__(self, device, output, **kwargs):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        """Create a new Image instance"""

        self.device = device
        self.out = output
        self.format = kwargs['format'] if 'format' in kwargs else 'raw'
        self.meta = kwargs['meta'] if 'meta' in kwargs else {}
        self.sysprep_params = \
            kwargs['sysprep_params'] if 'sysprep_params' in kwargs else {}
        self.progress_bar = None
        self.guestfs_device = None
        self.size = 0

        self.g = guestfs.GuestFS()
        self.guestfs_enabled = False
        self.guestfs_version = self.g.version()

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        # This is needed if the image format is not raw
        self.nbd = QemuNBD(device)

        if self.nbd.qemu_nbd is None and self.format != 'raw':
            raise FatalError("qemu-nbd command is missing, only raw input "
                             "media are supported")

        # Check If MOUNT LOCAL is supported for this guestfs build
        self.mount_local_support = hasattr(self.g, "mount_local")
        if self.mount_local_support:
            self._mount_thread = None
    def check_guestfs_version(self, major, minor, release):
        """Checks if the version of the used libguestfs is smaller, equal or
        greater than the one specified by the major, minor and release triplet

        Returns:
            < 0 if the installed version is smaller than the specified one
            = 0 if they are equal
            > 0 if the installed one is greater than the specified one
        """

        for (a, b) in (self.guestfs_version['major'], major), \
                (self.guestfs_version['minor'], minor), \
                (self.guestfs_version['release'], release):
            if a != b:
                return a - b

        return 0

    def enable(self):
        """Enable a newly created Image instance"""

        self.enable_guestfs()

        self.out.info('Inspecting Operating System ...', False)
        roots = self.g.inspect_os()

        if len(roots) == 0 or len(roots) > 1:
            self.root = None
            self.ostype = "unsupported"
            self.distro = "unsupported"
            self.guestfs_device = '/dev/sda'
            self.size = self.g.blockdev_getsize64(self.guestfs_device)
            if len(roots) > 1:
                reason = "Multiple operating systems found on the media."
                reason = "Unable to detect any operating system on the media."
            self.set_unsupported(reason)
        self.meta['PARTITION_TABLE'] = self.g.part_get_parttype('/dev/sda')
        self.guestfs_device = '/dev/sda'  # self.g.part_to_dev(self.root)
        self.size = self.g.blockdev_getsize64(self.guestfs_device)

        self.ostype = self.g.inspect_get_type(self.root)
        self.distro = self.g.inspect_get_distro(self.root)
        self.out.success(
            'found a(n) %s system' %
            self.ostype if self.distro == "unknown" else self.distro)

        # Inspect the OS
        self.os.inspect()

    def set_unsupported(self, reason):
        """Flag this image as unsupported"""

        self._unsupported = reason
        self.meta['UNSUPPORTED'] = reason
        self.out.warn('Media is not supported. Reason: %s' % reason)

    def is_unsupported(self):
        """Returns if this image is unsupported"""
        return hasattr(self, '_unsupported')

    def enable_guestfs(self):
        """Enable the guestfs handler"""

        if self.guestfs_enabled:
            self.out.warn("Guestfs is already enabled")
            return

        # Before version 1.18.4 the behavior of kill_subprocess was different
        # and you need to reset the guestfs handler to relaunch a previously
        # shut down QEMU backend
        if self.check_guestfs_version(1, 18, 4) < 0:
            self.g = guestfs.GuestFS()

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        self.g.add_drive_opts(self.device, readonly=0)

        # Before version 1.17.14 the recovery process, which is a fork of the
        # original process that called libguestfs, did not close its inherited
        # file descriptors. This can cause problems especially if the parent
        # process has opened pipes. Since the recovery process is an optional
        # feature of libguestfs, it's better to disable it.
        if self.check_guestfs_version(1, 17, 14) >= 0:
            self.out.info("Enabling recovery process ...", False)
            self.g.set_recovery_proc(1)
            self.out.success('done')
        else:
            self.g.set_recovery_proc(0)
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        # self.g.set_trace(1)
        # self.g.set_verbose(1)
        self.out.info('Launching helper VM (may take a while) ...', False)
        # self.progressbar = self.out.Progress(100, "Launching helper VM",
        #                                     "percent")
        # eh = self.g.set_event_callback(self.progress_callback,
        #                               guestfs.EVENT_PROGRESS)
        try:
            self.g.launch()
        except RuntimeError as e:
            raise FatalError(
                "Launching libguestfs's helper VM failed!\nReason: %s.\n\n"
                "Please run `libguestfs-test-tool' for more info." % str(e))
        self.guestfs_enabled = True
        # self.g.delete_event_callback(eh)
        # self.progressbar.success('done')
        # self.progressbar = None
        if self.check_guestfs_version(1, 18, 4) < 0:
            self.g.inspect_os()  # some calls need this

        self.out.success('done')

    def disable_guestfs(self):
        """Disable the guestfs handler"""
        if not self.guestfs_enabled:
            self.out.warn("Guestfs is already disabled")
            return

        self.out.info("Shutting down helper VM ...", False)
        # guestfs_shutdown which is the preferred way to shutdown the backend
        # process was introduced in version 1.19.16
        if self.check_guestfs_version(1, 19, 16) >= 0:
            self.g.shutdown()
        else:
            self.g.kill_subprocess()

        # We will reset the guestfs handler if needed
        if self.check_guestfs_version(1, 18, 4) < 0:
            self.g.close()

        self.guestfs_enabled = False
        self.out.success('done')
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        """Return an OS class instance for this image"""
        if hasattr(self, "_os"):
            return self._os

        if not self.guestfs_enabled:
            self.enable()

        cls = os_cls(self.distro, self.ostype)
        self._os = cls(self, sysprep_params=self.sysprep_params)
        self._os.collect_metadata()
Nikos Skalkotos's avatar
Nikos Skalkotos committed
    def raw_device(self, readonly=True):
        """Returns a context manager that exports the raw image device. If
        readonly is true, the block device that is returned is read only.
        """

        if self.guestfs_enabled:
            self.g.umount_all()
            self.g.sync()
            self.g.drop_caches(3)  # drop everything

        # Self gets overwritten
        img = self

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        class RawImage(object):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
            """The RawImage context manager"""
            def __enter__(self):
                return img.device if img.format == 'raw' else \
Nikos Skalkotos's avatar
Nikos Skalkotos committed
                    img.nbd.connect(readonly)
Nikos Skalkotos's avatar
Nikos Skalkotos committed
            def __exit__(self, exc_type, exc_value, traceback):
                if img.format != 'raw':
Nikos Skalkotos's avatar
Nikos Skalkotos committed
                    img.nbd.disconnect()

        return RawImage()

    def destroy(self):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        """Destroy this Image instance."""

        # In new guestfs versions, there is a handy shutdown method for this
        try:
            if self.guestfs_enabled:
                self.g.umount_all()
                self.g.sync()
        finally:
            # Close the guestfs handler if open
            self.g.close()

#    def progress_callback(self, ev, eh, buf, array):
#        position = array[2]
#        total = array[3]
#
#        self.progressbar.goto((position * 100) // total)

    def _last_partition(self):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        """Return the last partition of the image disk"""
        if self.meta['PARTITION_TABLE'] not in 'msdos' 'gpt':
            msg = "Unsupported partition table: %s. Only msdos and gpt " \
                "partition tables are supported" % self.meta['PARTITION_TABLE']
            raise FatalError(msg)

        def is_extended(partition):
            return self.g.part_get_mbr_id(
                self.guestfs_device, partition['part_num']) in (0x5, 0xf)

        def is_logical(partition):
            return self.meta['PARTITION_TABLE'] == 'msdos' and \
                partition['part_num'] > 4

        partitions = self.g.part_list(self.guestfs_device)
        last_partition = partitions[-1]

        if is_logical(last_partition):
            # The disk contains extended and logical partitions....
Nikos Skalkotos's avatar
Nikos Skalkotos committed
            extended = [p for p in partitions if is_extended(p)][0]
            last_primary = [p for p in partitions if p['part_num'] <= 4][-1]

            # check if extended is the last primary partition
            if last_primary['part_num'] > extended['part_num']:
                last_partition = last_primary

        return last_partition

Nikos Skalkotos's avatar
Nikos Skalkotos committed
    def shrink(self, silent=False):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        """Shrink the image.
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        This is accomplished by shrinking the last file system of the
        image and then updating the partition table. The shrinked device is
        returned.
        ATTENTION: make sure umount is called before shrink
        def get_fstype(partition):
            """Get file system type"""
            device = "%s%d" % (self.guestfs_device, partition['part_num'])
            return self.g.vfs_type(device)

        def is_logical(partition):
            """Returns True if the partition is a logical partition"""
            return self.meta['PARTITION_TABLE'] == 'msdos' and \
                partition['part_num'] > 4

        def is_extended(partition):
            """Returns True if the partition is an extended partition"""
            if self.meta['PARTITION_TABLE'] == 'msdos':
                mbr_id = self.g.part_get_mbr_id(self.guestfs_device,
                                                partition['part_num'])
                return mbr_id in (0x5, 0xf)
            return False

        def part_add(ptype, start, stop):
            """Add partition"""
            self.g.part_add(self.guestfs_device, ptype, start, stop)

        def part_del(partnum):
            """Delete a partition"""
            self.g.part_del(self.guestfs_device, partnum)

        def part_get_id(partnum):
            """Returns the MBR id of the partition"""
            return self.g.part_get_mbr_id(self.guestfs_device, partnum)

        def part_set_id(partnum, id):
            """Sets the MBR id of the partition"""
            self.g.part_set_mbr_id(self.guestfs_device, partnum, id)

        def part_get_bootable(partnum):
            """Returns the bootable flag of the partition"""
            return self.g.part_get_bootable(self.guestfs_device, partnum)

        def part_set_bootable(partnum, bootable):
            """Sets the bootable flag for a partition"""
            self.g.part_set_bootable(self.guestfs_device, partnum, bootable)
        if self.is_unsupported():
Nikos Skalkotos's avatar
Nikos Skalkotos committed
            if not silent:
                self.out.warn("Shrinking is disabled for unsupported images")
        sector_size = self.g.blockdev_getss(self.guestfs_device)

        last_part = None
        fstype = None
        while True:
            last_part = self._last_partition()
            fstype = get_fstype(last_part)

            if fstype == 'swap':
                self.meta['SWAP'] = "%d:%s" % \
                    (last_part['part_num'],
                     (last_part['part_size'] + MB - 1) // MB)
                part_del(last_part['part_num'])
                continue
            elif is_extended(last_part):
                part_del(last_part['part_num'])
                continue

            # Most disk manipulation programs leave 2048 sectors after the last
            # partition
            new_size = last_part['part_end'] + 1 + 2048 * sector_size
            self.size = min(self.size, new_size)
            break

        if not re.match("ext[234]", fstype):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
            if not silent:
                self.out.warn(
                    "Don't know how to shrink %s partitions." % fstype)

        part_dev = "%s%d" % (self.guestfs_device, last_part['part_num'])
        try:
            if self.check_guestfs_version(1, 15, 17) >= 0:
                self.g.e2fsck(part_dev, forceall=1)
            else:
                self.g.e2fsck_f(part_dev)
        except RuntimeError as e:
            # There is a bug in some versions of libguestfs and a RuntimeError
            # is thrown although the command has successfully corrected the
            # found file system errors.
            if e.message.find('***** FILE SYSTEM WAS MODIFIED *****') == -1:
                raise
        self.g.resize2fs_M(part_dev)

        out = self.g.tune2fs_l(part_dev)
        block_size = int(filter(lambda x: x[0] == 'Block size', out)[0][1])
        block_cnt = int(filter(lambda x: x[0] == 'Block count', out)[0][1])

        start = last_part['part_start'] / sector_size
        end = start + (block_size * block_cnt) / sector_size - 1

        if is_logical(last_part):
            partitions = self.g.part_list(self.guestfs_device)

            logical = []  # logical partitions
            for partition in partitions:
                if partition['part_num'] < 4:
                    continue
                logical.append({
                    'num': partition['part_num'],
                    'start': partition['part_start'] / sector_size,
                    'end': partition['part_end'] / sector_size,
                    'id': part_get_id(partition['part_num']),
                    'bootable': part_get_bootable(partition['part_num'])
                })

            logical[-1]['end'] = end  # new end after resize

            # Recreate the extended partition
            extended = filter(is_extended, partitions)[0]
            part_del(extended['part_num'])
            part_add('e', extended['part_start'] / sector_size, end)

            # Create all the logical partitions back
            for l in logical:
                part_add('l', l['start'], l['end'])
                part_set_id(l['num'], l['id'])
                part_set_bootable(l['num'], l['bootable'])
        else:
            # Recreate the last partition
            if self.meta['PARTITION_TABLE'] == 'msdos':
                last_part['id'] = part_get_id(last_part['part_num'])

            last_part['bootable'] = part_get_bootable(last_part['part_num'])
            part_del(last_part['part_num'])
            part_add('p', start, end)
            part_set_bootable(last_part['part_num'], last_part['bootable'])

            if self.meta['PARTITION_TABLE'] == 'msdos':
                part_set_id(last_part['part_num'], last_part['id'])

        new_size = (end + 1) * sector_size

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        assert new_size <= self.size

        if self.meta['PARTITION_TABLE'] == 'gpt':
Nikos Skalkotos's avatar
Nikos Skalkotos committed
            with self.raw_device(readonly=False) as raw:
                ptable = GPTPartitionTable(raw)
                self.size = ptable.shrink(new_size, self.size)
        else:
            self.size = min(new_size + 2048 * sector_size, self.size)

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        if not silent:
            self.out.success("Image size is %dMB" %
                             ((self.size + MB - 1) // MB))
    def mount(self, mpoint, readonly=False):
        """Mount the image file system under a local directory"""

        assert self.mount_local_support, \
            "MOUNT LOCAL not supported for this build of libguestfs"

        assert self._mount_thread is None, "Image is already mounted"

        self.os.mount(readonly=readonly, silent=True)
        if self.g.mount_local(mpoint, readonly=readonly) == -1:
            return

        # The thread will block in mount_local_run until the file
        self._mount_thread = threading.Thread(target=self.g.mount_local_run)
        self._mount_thread.mpoint = mpoint
        self._mount_thread.start()

    def is_mounted(self):
        """Check if the image is mounted"""

        assert self.mount_local_support, \
            "MOUNT LOCAL not supported for this build of libguestfs"

        if self._mount_thread is None:
            return False

        # Wait for 0.1 second to avoid race conditions if the thread is in an
        # initialization state but not alive yet.
        self._mount_thread.join(0.1)

        return self._mount_thread.is_alive()

    def umount(self, lazy=False):
        """umount the previously mounted image file system"""

        assert self.mount_local_support, \
            "MOUNT LOCAL not supported for this build of libguestfs"

        assert self._mount_thread is not None, "Image is not mounted"

        try:
            # Maybe the image was umounted externally
            if not self._mount_thread.is_alive():
                self._mount_thread = None
                return True

            try:
                args = (['-l'] if lazy else []) + [self._mount_thread.mpoint]
                get_command('umount')(*args)
            except:
                return False
            # Wait for a little while. If the image is umounted,
            # mount_local_run should have terminated
            self._mount_thread.join(5)
            if self._mount_thread.is_alive():
                raise FatalError('Unable to join the mount thread')
            self._mount_thread = None
            return True
        finally:
            self.os.umount()
    def dump(self, outfile):
Nikos Skalkotos's avatar
Nikos Skalkotos committed
        """Dumps the content of the image into a file.

        This method will only dump the actual payload, found by reading the
        partition table. Empty space in the end of the device will be ignored.
        """
        MB = 2 ** 20
        blocksize = 2 ** 22  # 4MB
        progr_size = (self.size + MB - 1) // MB  # in MB
        progressbar = self.out.Progress(progr_size, "Dumping image file", 'mb')

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        with self.raw_device() as raw:
            with open(raw, 'rb') as src:
                with open(outfile, "wb") as dst:
                    left = self.size
                    offset = 0
                    progressbar.next()
                    while left > 0:
                        length = min(left, blocksize)
                        sent = sendfile(dst.fileno(), src.fileno(), offset,
                                        length)

                        # Workaround for python-sendfile API change. In
                        # python-sendfile 1.2.x (py-sendfile) the returning
                        # value of sendfile is a tuple, where in version 2.x
                        # (pysendfile) it is just a single integer.
                        if isinstance(sent, tuple):
                            sent = sent[1]

                        offset += sent
                        left -= sent
                        progressbar.goto((self.size - left) // MB)

        progressbar.success('image file %s was successfully created' % outfile)

    def md5(self):
        """Computes the MD5 checksum of the image"""

        MB = 2 ** 20
        blocksize = 2 ** 22  # 4MB
        progr_size = ((self.size + MB - 1) // MB)  # in MB
        progressbar = self.out.Progress(progr_size, "Calculating md5sum", 'mb')
        md5 = hashlib.md5()

Nikos Skalkotos's avatar
Nikos Skalkotos committed
        with self.raw_device() as raw:
            with open(raw, "rb") as src:
                left = self.size
                while left > 0:
                    length = min(left, blocksize)
                    data = src.read(length)
                    md5.update(data)
                    left -= length
                    progressbar.goto((self.size - left) // MB)

        checksum = md5.hexdigest()
        progressbar.success(checksum)

        return checksum


# vim: set sta sts=4 shiftwidth=4 sw=4 et ai :