Newer
Older
# 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 <http://www.gnu.org/licenses/>.
from image_creator.util import FatalError, QemuNBD, get_command
from image_creator.gpt import GPTPartitionTable
from image_creator.os_type import os_cls
# 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):
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 {}
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()
# 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)
reason = "Multiple operating systems found on the media."
reason = "Unable to detect any operating system on the media."
self.set_unsupported(reason)
self.root = roots[0]
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)
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
if self.check_guestfs_version(1, 18, 4) < 0:
self.g = guestfs.GuestFS()
# 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)
else:
self.g.set_recovery_proc(0)
# 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
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')
@property
def os(self):
"""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()
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
"""The RawImage context manager"""
def __enter__(self):
return img.device if img.format == 'raw' else \
def __exit__(self, exc_type, exc_value, traceback):
img.nbd.disconnect()
return RawImage()
# 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):
"""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....
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
This is accomplished by shrinking the last file system of the
image and then updating the partition table. The new disk size
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():
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):
if not silent:
self.out.warn(
"Don't know how to shrink %s partitions." % fstype)
return self.size
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
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
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
if self.meta['PARTITION_TABLE'] == 'gpt':
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)
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()
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')
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()
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 :