diff options
author | Lucas Alvares Gomes <lucasagomes@gmail.com> | 2015-02-19 13:42:39 +0000 |
---|---|---|
committer | Lucas Alvares Gomes <lucasagomes@gmail.com> | 2015-03-04 16:34:17 +0000 |
commit | d23e0170de23cfb22eecb728105ec6e102185a1b (patch) | |
tree | 6809aee81e7e6dc541787694c6a8d92c29948ba8 | |
parent | f0e21a68fb3c2813634c6b15501050f2e9fde5c1 (diff) | |
download | ironic-python-agent-d23e0170de23cfb22eecb728105ec6e102185a1b.tar.gz |
Add the image extension (for local boot)
Initially this extension supports installing a bootloader so the user
image can boot from the local disk.
Change-Id: Ia588aafc240b55119c02f1254addc0cf796f88c5
-rw-r--r-- | ironic_python_agent/errors.py | 12 | ||||
-rw-r--r-- | ironic_python_agent/extensions/image.py | 153 | ||||
-rw-r--r-- | ironic_python_agent/tests/extensions/image.py | 140 | ||||
-rw-r--r-- | setup.cfg | 1 |
4 files changed, 306 insertions, 0 deletions
diff --git a/ironic_python_agent/errors.py b/ironic_python_agent/errors.py index 26dac154..ee2df169 100644 --- a/ironic_python_agent/errors.py +++ b/ironic_python_agent/errors.py @@ -294,3 +294,15 @@ class ISCSIError(RESTError): '{1}. stdout: {2}. stderr: {3}') details = details.format(error_msg, exit_code, stdout, stderr) super(ISCSIError, self).__init__(details) + + +class DeviceNotFound(NotFound): + """Error raised when the disk or partition to deploy the image onto is + not found. + """ + + message = ('Error finding the disk or partition device to deploy ' + 'the image onto.') + + def __init__(self, details): + super(DeviceNotFound, self).__init__(details) diff --git a/ironic_python_agent/extensions/image.py b/ironic_python_agent/extensions/image.py new file mode 100644 index 00000000..1158eae4 --- /dev/null +++ b/ironic_python_agent/extensions/image.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import shlex +import shutil +import tempfile + +from oslo_concurrency import processutils + +from ironic_python_agent import errors +from ironic_python_agent.extensions import base +from ironic_python_agent import hardware +from ironic_python_agent.openstack.common import log +from ironic_python_agent import utils + +LOG = log.getLogger(__name__) + + +BIND_MOUNTS = ('/dev', '/sys', '/proc') + + +def _get_root_partition(device, root_uuid): + """Find the root partition of a given device.""" + LOG.debug("Find the root partition %(uuid)s on device %(dev)s", + {'dev': device, 'uuid': root_uuid}) + + try: + # Try to tell the kernel to re-read the partition table + try: + utils.execute('partx', '-u', device, attempts=3, + delay_on_retry=True) + except processutils.ProcessExecutionError: + LOG.warning("Couldn't re-read the partition table " + "on device %s" % device) + + report = utils.execute('lsblk', '-PbioKNAME,UUID,TYPE', device)[0] + for line in report.split('\n'): + part = {} + # Split into KEY=VAL pairs + vals = shlex.split(line) + for key, val in (v.split('=', 1) for v in vals): + part[key] = val.strip() + # Ignore non partition + if part.get('TYPE') != 'part': + continue + + if part.get('UUID') == root_uuid: + LOG.debug("Root partition %(uuid)s found on device " + "%(dev)s", {'uuid': root_uuid, 'dev': device}) + return '/dev/' + part.get('KNAME') + else: + error_msg = ("No root partition with UUID %(uuid)s found on " + "device %(dev)s" % {'uuid': root_uuid, 'dev': device}) + LOG.error(error_msg) + raise errors.DeviceNotFound(error_msg) + except processutils.ProcessExecutionError as e: + error_msg = ('Finding the root partition with UUID %(uuid)s on ' + 'device %(dev)s failed with %(err)s' % + {'uuid': root_uuid, 'dev': device, 'err': e}) + LOG.error(error_msg) + raise errors.CommandExecutionError(error_msg) + + +def _install_grub2(device, root_uuid): + """Install GRUB2 bootloader on a given device.""" + LOG.debug("Installing GRUB2 bootloader on device %s", device) + root_partition = _get_root_partition(device, root_uuid) + + try: + # Mount the partition and binds + path = tempfile.mkdtemp() + utils.execute('mount', root_partition, path) + for fs in BIND_MOUNTS: + utils.execute('mount', '-o', 'bind', fs, path + fs) + + binary_name = "grub" + if os.path.exists(os.path.join(path, 'usr/sbin/grub2-install')): + binary_name = "grub2" + + # Install grub + utils.execute('chroot %(path)s /bin/bash -c ' + '"/usr/sbin/%(bin)s-install %(dev)s"' % + {'path': path, 'bin': binary_name, 'dev': device}, + shell=True) + + # Generate the grub configuration file + utils.execute('chroot %(path)s /bin/bash -c ' + '"/usr/sbin/%(bin)s-mkconfig -o ' + '/boot/%(bin)s/grub.cfg"' % + {'path': path, 'bin': binary_name}, shell=True) + + LOG.info("GRUB2 successfully installed on %s", device) + + except processutils.ProcessExecutionError as e: + error_msg = ('Installing GRUB2 boot loader to device %(dev)s ' + 'failed with %(err)s. Attempted 3 times.' % + {'dev': device, 'err': e}) + LOG.error(error_msg) + raise errors.CommandExecutionError(error_msg) + + finally: + umount_warn_msg = "Unable to umount %(path)s. Error: %(error)s" + # Umount binds and partition + umount_binds_fail = False + for fs in BIND_MOUNTS: + try: + utils.execute('umount', path + fs, attempts=3, + delay_on_retry=True) + except processutils.ProcessExecutionError as e: + umount_binds_fail = True + LOG.warning(umount_warn_msg, {'path': path + fs, 'error': e}) + + # If umounting the binds succeed then we can try to delete it + if not umount_binds_fail: + try: + utils.execute('umount', path, attempts=3, delay_on_retry=True) + except processutils.ProcessExecutionError as e: + LOG.warning(umount_warn_msg, {'path': path, 'error': e}) + else: + # After everything is umounted we can then remove the + # temporary directory + shutil.rmtree(path) + + +class ImageExtension(base.BaseAgentExtension): + + @base.sync_command('install_bootloader') + def install_bootloader(self, root_uuid): + """Install the GRUB2 bootloader on the image. + + :param root_uuid: The UUID of the root partition. + :raises: CommandExecutionError if the installation of the + bootloader fails. + :raises: DeviceNotFound if the root partition is not found. + + """ + device = hardware.dispatch_to_managers('get_os_install_device') + _install_grub2(device, root_uuid) diff --git a/ironic_python_agent/tests/extensions/image.py b/ironic_python_agent/tests/extensions/image.py new file mode 100644 index 00000000..fc23e71a --- /dev/null +++ b/ironic_python_agent/tests/extensions/image.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import shutil +import tempfile + +from oslo_concurrency import processutils +from oslotest import base as test_base + +from ironic_python_agent import errors +from ironic_python_agent.extensions import image +from ironic_python_agent import hardware +from ironic_python_agent import utils + + +@mock.patch.object(hardware, 'dispatch_to_managers') +@mock.patch.object(utils, 'execute') +@mock.patch.object(tempfile, 'mkdtemp', lambda *_: '/tmp/fake-dir') +@mock.patch.object(shutil, 'rmtree', lambda *_: None) +class TestImageExtension(test_base.BaseTestCase): + + def setUp(self): + super(TestImageExtension, self).setUp() + self.agent_extension = image.ImageExtension() + self.fake_dev = '/dev/fake' + self.fake_root_part = '/dev/fake2' + self.fake_root_uuid = '11111111-2222-3333-4444-555555555555' + self.fake_dir = '/tmp/fake-dir' + + @mock.patch.object(image, '_install_grub2') + def test_install_bootloader(self, mock_grub2, mock_execute, mock_dispatch): + mock_dispatch.return_value = self.fake_dev + self.agent_extension.install_bootloader(root_uuid=self.fake_root_uuid) + mock_dispatch.assert_called_once_with('get_os_install_device') + mock_grub2.assert_called_once_with(self.fake_dev, self.fake_root_uuid) + + @mock.patch.object(image, '_get_root_partition') + def test__install_grub2(self, mock_get_root, mock_execute, mock_dispatch): + mock_get_root.return_value = self.fake_root_part + image._install_grub2(self.fake_dev, self.fake_root_uuid) + + expected = [mock.call('mount', '/dev/fake2', self.fake_dir), + mock.call('mount', '-o', 'bind', '/dev', + self.fake_dir + '/dev'), + mock.call('mount', '-o', 'bind', '/sys', + self.fake_dir + '/sys'), + mock.call('mount', '-o', 'bind', '/proc', + self.fake_dir + '/proc'), + mock.call(('chroot %s /bin/bash -c ' + '"/usr/sbin/grub-install %s"' % + (self.fake_dir, self.fake_dev)), shell=True), + mock.call(('chroot %s /bin/bash -c ' + '"/usr/sbin/grub-mkconfig -o ' + '/boot/grub/grub.cfg"' % self.fake_dir), + shell=True), + mock.call('umount', self.fake_dir + '/dev', + attempts=3, delay_on_retry=True), + mock.call('umount', self.fake_dir + '/sys', + attempts=3, delay_on_retry=True), + mock.call('umount', self.fake_dir + '/proc', + attempts=3, delay_on_retry=True), + mock.call('umount', self.fake_dir, attempts=3, + delay_on_retry=True)] + mock_execute.assert_has_calls(expected) + mock_get_root.assert_called_once_with(self.fake_dev, + self.fake_root_uuid) + self.assertFalse(mock_dispatch.called) + + @mock.patch.object(image, '_get_root_partition') + def test__install_grub2_command_fail(self, mock_get_root, mock_execute, + mock_dispatch): + mock_get_root.return_value = self.fake_root_part + mock_execute.side_effect = processutils.ProcessExecutionError('boom') + + self.assertRaises(errors.CommandExecutionError, image._install_grub2, + self.fake_dev, self.fake_root_uuid) + + mock_get_root.assert_called_once_with(self.fake_dev, + self.fake_root_uuid) + self.assertFalse(mock_dispatch.called) + + def test__get_root_partition(self, mock_execute, mock_dispatch): + lsblk_output = ('''KNAME="test" UUID="" TYPE="disk" + KNAME="test1" UUID="256a39e3-ca3c-4fb8-9cc2-b32eec441f47" TYPE="part" + KNAME="test2" UUID="%s" TYPE="part"''' % self.fake_root_uuid) + mock_execute.side_effect = (None, [lsblk_output]) + + root_part = image._get_root_partition(self.fake_dev, + self.fake_root_uuid) + self.assertEqual('/dev/test2', root_part) + expected = [mock.call('partx', '-u', self.fake_dev, attempts=3, + delay_on_retry=True), + mock.call('lsblk', '-PbioKNAME,UUID,TYPE', self.fake_dev)] + mock_execute.assert_has_calls(expected) + self.assertFalse(mock_dispatch.called) + + def test__get_root_partition_no_device_found(self, mock_execute, + mock_dispatch): + lsblk_output = ('''KNAME="test" UUID="" TYPE="disk" + KNAME="test1" UUID="256a39e3-ca3c-4fb8-9cc2-b32eec441f47" TYPE="part" + KNAME="test2" UUID="" TYPE="part"''') + mock_execute.side_effect = (None, [lsblk_output]) + + self.assertRaises(errors.DeviceNotFound, + image._get_root_partition, self.fake_dev, + self.fake_root_uuid) + expected = [mock.call('partx', '-u', self.fake_dev, attempts=3, + delay_on_retry=True), + mock.call('lsblk', '-PbioKNAME,UUID,TYPE', self.fake_dev)] + mock_execute.assert_has_calls(expected) + self.assertFalse(mock_dispatch.called) + + def test__get_root_partition_command_fail(self, mock_execute, + mock_dispatch): + mock_execute.side_effect = (None, + processutils.ProcessExecutionError('boom')) + self.assertRaises(errors.CommandExecutionError, + image._get_root_partition, self.fake_dev, + self.fake_root_uuid) + + expected = [mock.call('partx', '-u', self.fake_dev, attempts=3, + delay_on_retry=True), + mock.call('lsblk', '-PbioKNAME,UUID,TYPE', self.fake_dev)] + mock_execute.assert_has_calls(expected) + self.assertFalse(mock_dispatch.called) @@ -23,6 +23,7 @@ ironic_python_agent.extensions = decom = ironic_python_agent.extensions.decom:DecomExtension flow = ironic_python_agent.extensions.flow:FlowExtension iscsi = ironic_python_agent.extensions.iscsi:ISCSIExtension + image = ironic_python_agent.extensions.image:ImageExtension ironic_python_agent.hardware_managers = generic = ironic_python_agent.hardware:GenericHardwareManager |