Add implementation for virt-builder source which aims to create container root file system from VM image build with virt-builder. Usage examples: virt-bootstrap virt-builder://fedora-25 /tmp/foo virt-bootstrap virt-builder://ubuntu-16.04 /tmp/bar --root-password secret virt-bootstrap virt-builder://fedora-25 /tmp/foo -f qcow2 --idmap 0:1000:10 sudo virt-bootstrap virt-builder://fedora-25 /tmp/foo --idmap 0:1000:10 Tests are also introduced along with the implementation. They cover creation of root file system and UID/GID mapping for 'dir' and 'qcow2' output format by mocking the build_image() method to avoid the time consuming call to virt-builder which might also require network connection with function which creates dummy disk image. Setting root password is handled by virt-builder and hence the introduced test only ensures that the password string is passed correctly. --- src/virtBootstrap/sources/__init__.py | 1 + src/virtBootstrap/sources/virt_builder_source.py | 148 +++++++++++++++ src/virtBootstrap/virt_bootstrap.py | 4 +- tests/__init__.py | 23 +++ tests/file_source.py | 28 +-- tests/virt_builder_source.py | 228 +++++++++++++++++++++++ 6 files changed, 406 insertions(+), 26 deletions(-) create mode 100644 src/virtBootstrap/sources/virt_builder_source.py create mode 100644 tests/virt_builder_source.py diff --git a/src/virtBootstrap/sources/__init__.py b/src/virtBootstrap/sources/__init__.py index e891e9b..be6b25c 100644 --- a/src/virtBootstrap/sources/__init__.py +++ b/src/virtBootstrap/sources/__init__.py @@ -24,3 +24,4 @@ sources - Class definitions which process container image or from virtBootstrap.sources.file_source import FileSource from virtBootstrap.sources.docker_source import DockerSource +from virtBootstrap.sources.virt_builder_source import VirtBuilderSource diff --git a/src/virtBootstrap/sources/virt_builder_source.py b/src/virtBootstrap/sources/virt_builder_source.py new file mode 100644 index 0000000..780ffb1 --- /dev/null +++ b/src/virtBootstrap/sources/virt_builder_source.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Authors: Radostin Stoyanov <rstoyanov1@xxxxxxxxx> +# +# Copyright (C) 2017 Radostin Stoyanov +# +# 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/>. + +""" +VirtBuilderSource aim is to extract the root file system from VM image +build with virt-builder from template. +""" + +import os +import logging +import subprocess +import tempfile + +import guestfs +from virtBootstrap import utils + + +# pylint: disable=invalid-name +# Create logger +logger = logging.getLogger(__name__) + + +class VirtBuilderSource(object): + """ + Extract root file system from image build with virt-builder. + """ + def __init__(self, **kwargs): + """ + Create container rootfs by building VM from virt-builder template + and extract the rootfs. + + @param uri: Template name + @param fmt: Format used to store the output [dir, qcow2] + @param uid_map: Mappings for UID of files in rootfs + @param gid_map: Mappings for GID of files in rootfs + @param root_password: Root password to set in rootfs + @param progress: Instance of the progress module + """ + # Parsed URIs: + # - "virt-builder:///<template>" + # - "virt-builder://<template>" + # - "virt-builder:/<template>" + self.template = kwargs['uri'].netloc or kwargs['uri'].path[1:] + self.output_format = kwargs.get('fmt', utils.DEFAULT_OUTPUT_FORMAT) + self.uid_map = kwargs.get('uid_map', []) + self.gid_map = kwargs.get('gid_map', []) + self.root_password = kwargs.get('root_password', None) + self.progress = kwargs['progress'].update_progress + + def build_image(self, output_file): + """ + Build VM from virt-builder template + """ + cmd = ['virt-builder', self.template, + '-o', output_file, + '--no-network', + '--delete', '/dev/*', + '--delete', '/boot/*', + # Comment out all lines in fstab + '--edit', '/etc/fstab:s/^/#/'] + if self.root_password is not None: + cmd += ['--root-password', "password:%s" % self.root_password] + subprocess.check_call(cmd) + + def unpack(self, dest): + """ + Build image and extract root file system + + @param dest: Directory path where output files will be stored. + """ + + with tempfile.NamedTemporaryFile(prefix='bootstrap_') as tmp_file: + if self.output_format == 'dir': + self.progress("Building image", value=0, logger=logger) + self.build_image(tmp_file.name) + self.progress("Extracting rootfs", value=50, logger=logger) + g = guestfs.GuestFS(python_return_dict=True) + g.add_drive_opts(tmp_file.name, readonly=False, format='raw') + g.launch() + + # Get the device with file system + root_dev = g.inspect_os() + if not root_dev: + raise Exception("No file system was found") + g.mount(root_dev[0], '/') + + # Extract file system to destination directory + g.copy_out('/', dest) + + g.umount('/') + g.shutdown() + + self.progress("Extraction completed successfully!", + value=100, logger=logger) + logger.info("Files are stored in: %s", dest) + + elif self.output_format == 'qcow2': + output_file = os.path.join(dest, 'layer-0.qcow2') + + self.progress("Building image", value=0, logger=logger) + self.build_image(tmp_file.name) + + self.progress("Extracting rootfs", value=50, logger=logger) + g = guestfs.GuestFS(python_return_dict=True) + g.add_drive_opts(tmp_file.name, readonly=True, format='raw') + # Create qcow2 disk image + g.disk_create( + filename=output_file, + format='qcow2', + size=os.path.getsize(tmp_file.name) + ) + g.add_drive_opts(output_file, readonly=False, format='qcow2') + g.launch() + # Get the device with file system + root_dev = g.inspect_os() + if not root_dev: + raise Exception("No file system was found") + output_dev = g.list_devices()[1] + # Copy the file system to the new qcow2 disk + g.copy_device_to_device(root_dev[0], output_dev, sparse=True) + g.shutdown() + + # UID/GID mapping + if self.uid_map or self.gid_map: + logger.info("Mapping UID/GID") + utils.map_id_in_image(1, dest, self.uid_map, self.gid_map) + + self.progress("Extraction completed successfully!", value=100, + logger=logger) + logger.info("Image is stored in: %s", output_file) + + else: + raise Exception("Unknown format:" + self.output_format) diff --git a/src/virtBootstrap/virt_bootstrap.py b/src/virtBootstrap/virt_bootstrap.py index e387842..68e6516 100755 --- a/src/virtBootstrap/virt_bootstrap.py +++ b/src/virtBootstrap/virt_bootstrap.py @@ -62,7 +62,7 @@ def get_source(source_type): Get object which match the source type """ try: - class_name = "%sSource" % source_type.capitalize() + class_name = "%sSource" % source_type.title().replace('-', '') clazz = getattr(sources, class_name) return clazz except Exception: @@ -179,6 +179,8 @@ def main(): docker://docker.io/fedora docker://privateregistry:5000/image file:///path/to/local/rootfs.tar.xz + virt-builder://fedora-25 + virt-builder://ubuntu-16.04 ---------------------------------------- '''))) diff --git a/tests/__init__.py b/tests/__init__.py index 7a53c38..8888a0d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,6 +29,8 @@ import tarfile import unittest import passlib.hosts +import guestfs + try: import mock except ImportError: @@ -434,3 +436,24 @@ class BuildTarFiles(unittest.TestCase): self.check_image_content(g, user, 'dirs', g.is_dir) # Check files self.check_image_content(g, user, 'files', g.is_file) + + def check_qcow2_image(self, image_path): + """ + Ensures that qcow2 images contain all files. + """ + g = guestfs.GuestFS(python_return_dict=True) + g.add_drive_opts(image_path, readonly=True) + g.launch() + g.mount('/dev/sda', '/') + self.check_image(g) + g.umount('/') + g.shutdown() + + def get_image_path(self, n=0): + """ + Returns the path where the qcow2 image will be stored. + """ + return os.path.join( + self.dest_dir, + "layer-%d.qcow2" % n + ) diff --git a/tests/file_source.py b/tests/file_source.py index 8851c3f..895ba9e 100644 --- a/tests/file_source.py +++ b/tests/file_source.py @@ -24,7 +24,6 @@ with FileSource. import os import unittest -import guestfs from . import virt_bootstrap from . import BuildTarFiles @@ -38,27 +37,6 @@ class Qcow2BuildImage(BuildTarFiles): works as expected. """ - def check_qcow2_images(self, image_path): - """ - Ensures that qcow2 images contain all files. - """ - g = guestfs.GuestFS(python_return_dict=True) - g.add_drive_opts(image_path, readonly=True) - g.launch() - g.mount('/dev/sda', '/') - self.check_image(g) - g.umount('/') - g.shutdown() - - def get_image_path(self, n=0): - """ - Returns the path where the qcow2 image will be stored. - """ - return os.path.join( - self.dest_dir, - "layer-%d.qcow2" % n - ) - def runTest(self): """ Create qcow2 image from each dummy tarfile. @@ -69,7 +47,7 @@ class Qcow2BuildImage(BuildTarFiles): fmt='qcow2', progress_cb=mock.Mock() ) - self.check_qcow2_images(self.get_image_path()) + self.check_qcow2_image(self.get_image_path()) class Qcow2OwnershipMapping(Qcow2BuildImage): @@ -91,7 +69,7 @@ class Qcow2OwnershipMapping(Qcow2BuildImage): gid_map=self.gid_map ) self.apply_mapping() - self.check_qcow2_images(self.get_image_path(1)) + self.check_qcow2_image(self.get_image_path(1)) class Qcow2SettingRootPassword(Qcow2BuildImage): @@ -112,7 +90,7 @@ class Qcow2SettingRootPassword(Qcow2BuildImage): root_password=self.root_password ) self.check_image = self.validate_shadow_file_in_image - self.check_qcow2_images(self.get_image_path(1)) + self.check_qcow2_image(self.get_image_path(1)) @unittest.skipIf(os.geteuid() != 0, "Root privileges required") diff --git a/tests/virt_builder_source.py b/tests/virt_builder_source.py new file mode 100644 index 0000000..4fe7713 --- /dev/null +++ b/tests/virt_builder_source.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# Authors: Radostin Stoyanov <rstoyanov1@xxxxxxxxx> +# +# Copyright (C) 2017 Radostin Stoyanov +# +# 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/>. + + +""" +Regression tests which aim to excercise the creation of root file system +with VirtBuilderSource. +""" + +import os +import unittest + +import guestfs + +from . import BuildTarFiles +from . import virt_bootstrap +from . import mock + + +# pylint: disable=invalid-name +class DirExtractRootFS(BuildTarFiles): + """ + This test is replacing the method build_image() from VirtBuilderSource + with a function which generates gummy disk image. + + It ensures that all files and directories from the created root file + systems is extracted correctly. + """ + def create_tar_files(self): + """ + Don't need to build tar files for these tests. + """ + pass + + def create_dummy_disk(self, output_file): + """ + Create raw disk image with dummy root file system + """ + g = guestfs.GuestFS(python_return_dict=True) + g.disk_create(output_file, format='raw', size=(1 * 1024 * 1024)) + g.add_drive(output_file, readonly=False, format='raw') + g.launch() + g.mkfs('ext3', '/dev/sda') + g.mount('/dev/sda', '/') + for user in self.rootfs_tree: + + usr_uid = self.rootfs_tree[user]['uid'] + usr_gid = self.rootfs_tree[user]['gid'] + usr_dirs = self.rootfs_tree[user]['dirs'] + usr_files = self.rootfs_tree[user]['files'] + + for member in usr_dirs: + dir_name = '/' + member + g.mkdir_p(dir_name) + g.chown(usr_uid, usr_gid, dir_name) + + for member in usr_files: + if isinstance(member, tuple): + file_name = '/' + member[0] + g.write(file_name, member[2]) + g.chmod(member[1] & 0o777, file_name) + else: + file_name = '/' + member + g.touch(file_name) + g.chmod(0o755 & 0o777, file_name) + g.chown(usr_uid, usr_gid, file_name) + + def runTest(self): + """ + Mock the function build_image() and call bootstrap(). + Then check the extracted root file system. + """ + build_image = 'virtBootstrap.sources.VirtBuilderSource.build_image' + with mock.patch(build_image) as m_build_image: + m_build_image.side_effect = self.create_dummy_disk + virt_bootstrap.bootstrap( + uri='virt-builder://foobar', + dest=self.dest_dir, + fmt='dir', + progress_cb=mock.Mock() + ) + self.check_rootfs(skip_ownership=(os.geteuid() != 0)) + + +@unittest.skipIf(os.geteuid() != 0, "Root privileges required") +class DirOwnershipMapping(DirExtractRootFS): + """ + Ensures that UID/GID mapping for extracted root file system are applied + correctly. + """ + def runTest(self): + """ + Mock the function build_image() and call bootstrap() with uid/gid + mapping values. + Then check the ownership of the extracted root file system. + """ + self.uid_map = [[1000, 2000, 10], [0, 1000, 10], [500, 500, 10]] + self.gid_map = [[1000, 2000, 10], [0, 1000, 10], [500, 500, 10]] + build_image = 'virtBootstrap.sources.VirtBuilderSource.build_image' + with mock.patch(build_image) as m_build_image: + m_build_image.side_effect = self.create_dummy_disk + virt_bootstrap.bootstrap( + progress_cb=mock.Mock(), + uri='virt-builder://foobar', + dest=self.dest_dir, + fmt='dir', + uid_map=self.uid_map, + gid_map=self.gid_map + ) + self.apply_mapping() + self.check_rootfs(skip_ownership=(os.geteuid() != 0)) + + +class DirSettingRootPassword(DirExtractRootFS): + """ + The root password is set by virt-builder in this test we do not call + virt-builder as this is time consuming job and might require network + connection to download the VM template. + + Instead we only check if the root password is passed to virt-builder + and if virt-bootstrap extracts the shadow file correctly. + """ + def verify_virt_builder_cmd(self, cmd): + """ + Ensures that virt-builder is called with valid command and the root + password is passed. + """ + self.assertEqual( + 'virt-builder', + cmd[0], + "virt-builder command does not start with 'virt-builder'" + ) + self.assertIn( + '--root-password', + cmd, + "The flag '--root-password' is missing in virt-builder command" + ) + self.assertEqual( + 'password:%s' % self.root_password, + cmd[cmd.index('--root-password') + 1], + "Root password doesn't match" + ) + self.create_dummy_disk(cmd[cmd.index('-o') + 1]) + + def runTest(self): + """ + Mock the function subprocess.check_call() and call bootstrap(). + Then check the extracted root file system. + """ + self.root_password = "Root password" + with mock.patch('subprocess.check_call') as m_check_call: + m_check_call.side_effect = self.verify_virt_builder_cmd + virt_bootstrap.bootstrap( + uri='virt-builder://foobar', + dest=self.dest_dir, + fmt='dir', + root_password=self.root_password, + progress_cb=mock.Mock() + ) + m_check_call.assert_called_once() + self.validate_shadow_file( + os.path.join(self.dest_dir, 'etc/shadow'), + skip_ownership=(os.geteuid() != 0), + skip_hash=True + ) + + +class Qcow2BuildImage(DirExtractRootFS): + """ + Ensures that the file system is copied correctly to the output qcow2 image. + """ + def runTest(self): + """ + Mock the function build_image() and call bootstrap(). + Then check the content of the new image. + """ + build_image = 'virtBootstrap.sources.VirtBuilderSource.build_image' + with mock.patch(build_image) as m_build_image: + m_build_image.side_effect = self.create_dummy_disk + virt_bootstrap.bootstrap( + progress_cb=mock.Mock(), + uri='virt-builder://foobar', + dest=self.dest_dir, + fmt='qcow2' + ) + self.check_qcow2_image(self.get_image_path()) + + +class Qcow2OwnershipMapping(DirExtractRootFS): + """ + Ensures that UID/GID mapping is applied correctly with qcow2 conversion. + """ + def runTest(self): + """ + Mock the function build_image() and call bootstrap() with uid/gid + mapping values. + Then check the ownership of the extracted root file system. + """ + self.uid_map = [[1000, 2000, 10], [0, 1000, 10], [500, 500, 10]] + self.gid_map = [[1000, 2000, 10], [0, 1000, 10], [500, 500, 10]] + build_image = 'virtBootstrap.sources.VirtBuilderSource.build_image' + with mock.patch(build_image) as m_build_image: + m_build_image.side_effect = self.create_dummy_disk + virt_bootstrap.bootstrap( + uri='virt-builder://foobar', + dest=self.dest_dir, + fmt='qcow2', + uid_map=self.uid_map, + gid_map=self.gid_map, + progress_cb=mock.Mock() + ) + self.apply_mapping() + self.check_qcow2_image(self.get_image_path(1)) -- 2.13.5 _______________________________________________ virt-tools-list mailing list virt-tools-list@xxxxxxxxxx https://www.redhat.com/mailman/listinfo/virt-tools-list