From 614c4a2359123ad0ecac7ac0d168be8e246a5f9d Mon Sep 17 00:00:00 2001 From: Josh Kearney Date: Thu, 18 Jul 2013 13:57:54 -0500 Subject: [PATCH] xenapi: Added iPXE ISO boot support This patch adds support for a new kind of Glance image, a specially crafted ISO which supports iPXE booting, giving customers a means to roll their own image. Two virt-layer modifications were needed. The first was adding configurations for the iPXE ISO feature (network to use, boot menu, mkisofs_cmd). The second was the ability to inject networking info into the ISO after it was downloaded. To use this feature, operators should enable the `ipxe_boot` image-property. DocImpact Implements blueprint xenapi-ipxe-iso-boot-support Change-Id: I33acf9dfdff0a5ed9797723a142bc451348e8549 --- etc/nova/nova.conf.sample | 10 ++ nova/tests/virt/xenapi/test_xenapi.py | 53 +++++++ nova/virt/xenapi/vm_utils.py | 70 +++++++++ nova/virt/xenapi/vmops.py | 15 ++ .../rpmbuild/SPECS/openstack-xen-plugins.spec | 1 + .../xenserver/xenapi/etc/xapi.d/plugins/ipxe | 134 ++++++++++++++++++ .../xenapi/etc/xapi.d/plugins/utils.py | 12 +- 7 files changed, 294 insertions(+), 1 deletion(-) create mode 100755 plugins/xenserver/xenapi/etc/xapi.d/plugins/ipxe diff --git a/etc/nova/nova.conf.sample b/etc/nova/nova.conf.sample index a28e4fc1d869..587df079ffb8 100644 --- a/etc/nova/nova.conf.sample +++ b/etc/nova/nova.conf.sample @@ -2340,6 +2340,16 @@ # (all|some|none). (string value) #xenapi_torrent_images=none +# Name of network to use for booting iPXE ISOs (string value) +#xenapi_ipxe_network_name= + +# URL to the iPXE boot menu (string value) +#xenapi_ipxe_boot_menu_url= + +# Name and optionally path of the tool used for ISO image +# creation (string value) +#xenapi_ipxe_mkisofs_cmd=mkisofs + # # Options defined in nova.virt.xenapi.vmops diff --git a/nova/tests/virt/xenapi/test_xenapi.py b/nova/tests/virt/xenapi/test_xenapi.py index aa51a64a420c..dc64c676dd35 100644 --- a/nova/tests/virt/xenapi/test_xenapi.py +++ b/nova/tests/virt/xenapi/test_xenapi.py @@ -75,6 +75,7 @@ IMAGE_RAMDISK = '3' IMAGE_RAW = '4' IMAGE_VHD = '5' IMAGE_ISO = '6' +IMAGE_IPXE_ISO = '7' IMAGE_FIXTURES = { IMAGE_MACHINE: { @@ -107,6 +108,12 @@ IMAGE_FIXTURES = { 'disk_format': 'iso', 'container_format': 'bare'}, }, + IMAGE_IPXE_ISO: { + 'image_meta': {'name': 'fake_ipxe_iso', 'size': 0, + 'disk_format': 'iso', + 'container_format': 'bare', + 'properties': {'ipxe_boot': 'true'}}, + }, } @@ -737,6 +744,52 @@ class XenAPIVMTestCase(stubs.XenAPITestBase): self.assertTrue(instance['os_type']) self.assertTrue(instance['architecture']) + def test_spawn_ipxe_iso_success(self): + self.mox.StubOutWithMock(vm_utils, 'get_sr_path') + vm_utils.get_sr_path(mox.IgnoreArg()).AndReturn('/sr/path') + + self.flags(xenapi_ipxe_network_name='test1', + xenapi_ipxe_boot_menu_url='http://boot.example.com', + xenapi_ipxe_mkisofs_cmd='/root/mkisofs') + self.mox.StubOutWithMock(self.conn._session, 'call_plugin_serialized') + self.conn._session.call_plugin_serialized( + 'ipxe', 'inject', '/sr/path', mox.IgnoreArg(), + 'http://boot.example.com', '192.168.1.100', '255.255.255.0', + '192.168.1.1', '192.168.1.3', '/root/mkisofs') + + self.mox.ReplayAll() + self._test_spawn(IMAGE_IPXE_ISO, None, None) + + def test_spawn_ipxe_iso_no_network_name(self): + self.flags(xenapi_ipxe_network_name=None, + xenapi_ipxe_boot_menu_url='http://boot.example.com') + + # call_plugin_serialized shouldn't be called + self.mox.StubOutWithMock(self.conn._session, 'call_plugin_serialized') + + self.mox.ReplayAll() + self._test_spawn(IMAGE_IPXE_ISO, None, None) + + def test_spawn_ipxe_iso_no_boot_menu_url(self): + self.flags(xenapi_ipxe_network_name='test1', + xenapi_ipxe_boot_menu_url=None) + + # call_plugin_serialized shouldn't be called + self.mox.StubOutWithMock(self.conn._session, 'call_plugin_serialized') + + self.mox.ReplayAll() + self._test_spawn(IMAGE_IPXE_ISO, None, None) + + def test_spawn_ipxe_iso_unknown_network_name(self): + self.flags(xenapi_ipxe_network_name='test2', + xenapi_ipxe_boot_menu_url='http://boot.example.com') + + # call_plugin_serialized shouldn't be called + self.mox.StubOutWithMock(self.conn._session, 'call_plugin_serialized') + + self.mox.ReplayAll() + self._test_spawn(IMAGE_IPXE_ISO, None, None) + def test_spawn_empty_dns(self): # Test spawning with an empty dns list. self._test_spawn(IMAGE_VHD, None, None, diff --git a/nova/virt/xenapi/vm_utils.py b/nova/virt/xenapi/vm_utils.py index 1552f8d98d8b..4b512785b4fe 100644 --- a/nova/virt/xenapi/vm_utils.py +++ b/nova/virt/xenapi/vm_utils.py @@ -40,6 +40,7 @@ from nova.compute import power_state from nova.compute import task_states from nova import exception from nova.image import glance +from nova.network import model as network_model from nova.openstack.common import excutils from nova.openstack.common.gettextutils import _ from nova.openstack.common import importutils @@ -97,6 +98,14 @@ xenapi_vm_utils_opts = [ default='none', help='Whether or not to download images via Bit Torrent ' '(all|some|none).'), + cfg.StrOpt('xenapi_ipxe_network_name', + help='Name of network to use for booting iPXE ISOs'), + cfg.StrOpt('xenapi_ipxe_boot_menu_url', + help='URL to the iPXE boot menu'), + cfg.StrOpt('xenapi_ipxe_mkisofs_cmd', + default='mkisofs', + help='Name and optionally path of the tool used for ' + 'ISO image creation'), ] CONF = cfg.CONF @@ -2265,3 +2274,64 @@ def vm_ref_or_raise(session, instance_name): if vm_ref is None: raise exception.InstanceNotFound(instance_id=instance_name) return vm_ref + + +def handle_ipxe_iso(session, instance, cd_vdi, network_info): + """iPXE ISOs are a mechanism to allow the customer to roll their own + image. + + To use this feature, a service provider needs to configure the + appropriate Nova flags, roll an iPXE ISO, then distribute that image + to customers via Glance. + + NOTE: `mkisofs` is not present by default in the Dom0, so the service + provider can either add that package manually to Dom0 or include the + `mkisofs` binary in the image itself. + """ + boot_menu_url = CONF.xenapi_ipxe_boot_menu_url + if not boot_menu_url: + LOG.warn(_('xenapi_ipxe_boot_menu_url not set, user will have to' + ' enter URL manually...'), instance=instance) + return + + network_name = CONF.xenapi_ipxe_network_name + if not network_name: + LOG.warn(_('xenapi_ipxe_network_name not set, user will have to' + ' enter IP manually...'), instance=instance) + return + + network = None + for vif in network_info: + if vif['network']['label'] == network_name: + network = vif['network'] + break + + if not network: + LOG.warn(_("Unable to find network matching '%(network_name)s', user" + " will have to enter IP manually...") % + {'network_name': network_name}, instance=instance) + return + + sr_path = get_sr_path(session) + + # Unpack IPv4 network info + subnet = [sn for sn in network['subnets'] + if sn['version'] == 4][0] + ip = subnet['ips'][0] + + ip_address = ip['address'] + netmask = network_model.get_netmask(ip, subnet) + gateway = subnet['gateway']['address'] + dns = subnet['dns'][0]['address'] + + try: + session.call_plugin_serialized("ipxe", "inject", sr_path, + cd_vdi['uuid'], boot_menu_url, ip_address, netmask, + gateway, dns, CONF.xenapi_ipxe_mkisofs_cmd) + except session.XenAPI.Failure as exc: + _type, _method, error = exc.details[:3] + if error == 'CommandNotFound': + LOG.warn(_("ISO creation tool '%s' does not exist.") % + CONF.xenapi_ipxe_mkisofs_cmd, instance=instance) + else: + raise diff --git a/nova/virt/xenapi/vmops.py b/nova/virt/xenapi/vmops.py index 7632e1f2828f..b7046fb4c87f 100644 --- a/nova/virt/xenapi/vmops.py +++ b/nova/virt/xenapi/vmops.py @@ -43,6 +43,7 @@ from nova.openstack.common.gettextutils import _ from nova.openstack.common import importutils from nova.openstack.common import jsonutils from nova.openstack.common import log as logging +from nova.openstack.common import strutils from nova.openstack.common import timeutils from nova import utils from nova.virt import configdrive @@ -412,6 +413,20 @@ class VMOps(object): @step def attach_disks_step(undo_mgr, vm_ref, vdis, disk_image_type): + try: + ipxe_boot = strutils.bool_from_string( + image_meta['properties']['ipxe_boot']) + except KeyError: + ipxe_boot = False + + if ipxe_boot: + if 'iso' in vdis: + vm_utils.handle_ipxe_iso( + self._session, instance, vdis['iso'], network_info) + else: + LOG.warning(_('ipxe_boot is True but no ISO image found'), + instance=instance) + self._attach_disks(instance, vm_ref, name_label, vdis, disk_image_type, admin_password, injected_files) diff --git a/plugins/xenserver/xenapi/contrib/rpmbuild/SPECS/openstack-xen-plugins.spec b/plugins/xenserver/xenapi/contrib/rpmbuild/SPECS/openstack-xen-plugins.spec index 85c2d1c05dfc..84578cf54048 100644 --- a/plugins/xenserver/xenapi/contrib/rpmbuild/SPECS/openstack-xen-plugins.spec +++ b/plugins/xenserver/xenapi/contrib/rpmbuild/SPECS/openstack-xen-plugins.spec @@ -34,6 +34,7 @@ rm -rf $RPM_BUILD_ROOT /etc/xapi.d/plugins/config_file /etc/xapi.d/plugins/console /etc/xapi.d/plugins/glance +/etc/xapi.d/plugins/ipxe /etc/xapi.d/plugins/kernel /etc/xapi.d/plugins/migration /etc/xapi.d/plugins/pluginlib_nova.py diff --git a/plugins/xenserver/xenapi/etc/xapi.d/plugins/ipxe b/plugins/xenserver/xenapi/etc/xapi.d/plugins/ipxe new file mode 100755 index 000000000000..a41acb448e81 --- /dev/null +++ b/plugins/xenserver/xenapi/etc/xapi.d/plugins/ipxe @@ -0,0 +1,134 @@ +#!/usr/bin/env python + +# Copyright (c) 2013 Openstack Foundation +# +# 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. + +"""Inject network configuration into iPXE ISO for boot.""" + +import os +import shutil + +import utils + +#FIXME(sirp): should this use pluginlib from 5.6? +from pluginlib_nova import * +configure_logging('ipxe') + + +ISOLINUX_CFG = """SAY iPXE ISO boot image +TIMEOUT 30 +DEFAULT ipxe.krn +LABEL ipxe.krn + KERNEL ipxe.krn + INITRD netcfg.ipxe +""" + +NETCFG_IPXE = """#!ipxe +:start +imgfree +ifclose net0 +set net0/ip %(ip_address)s +set net0/netmask %(netmask)s +set net0/gateway %(gateway)s +set dns %(dns)s +ifopen net0 +goto menu + +:menu +chain %(boot_menu_url)s +goto boot + +:boot +sanboot --no-describe --drive 0x80 +""" + + +def _write_file(filename, data): + # If the ISO was tampered with such that the destination is a symlink, + # that could allow a malicious user to write to protected areas of the + # dom0 filesystem. /HT to comstud for pointing this out. + # + # Short-term, checking that the destination is not a symlink should be + # sufficient. + # + # Long-term, we probably want to perform all file manipulations within a + # chroot jail to be extra safe. + if os.path.islink(filename): + raise RuntimeError('SECURITY: Cannot write to symlinked destination') + + logging.debug("Writing to file '%s'" % filename) + f = open(filename, 'w') + try: + f.write(data) + finally: + f.close() + + +def _unbundle_iso(sr_path, filename, path): + logging.debug("Unbundling ISO '%s'" % filename) + read_only_path = utils.make_staging_area(sr_path) + try: + utils.run_command(['mount', '-o', 'loop', filename, read_only_path]) + try: + shutil.copytree(read_only_path, path) + finally: + utils.run_command(['umount', read_only_path]) + finally: + utils.cleanup_staging_area(read_only_path) + + +def _create_iso(mkisofs_cmd, filename, path): + logging.debug("Creating ISO '%s'..." % filename) + orig_dir = os.getcwd() + os.chdir(path) + try: + utils.run_command([mkisofs_cmd, '-quiet', '-l', '-o', filename, + '-c', 'boot.cat', '-b', 'isolinux.bin', + '-no-emul-boot', '-boot-load-size', '4', + '-boot-info-table', '.']) + finally: + os.chdir(orig_dir) + + +def inject(session, sr_path, vdi_uuid, boot_menu_url, ip_address, netmask, + gateway, dns, mkisofs_cmd): + + iso_filename = '%s.img' % os.path.join(sr_path, 'iso', vdi_uuid) + + # Create staging area so we have a unique path but remove it since + # shutil.copytree will recreate it + staging_path = utils.make_staging_area(sr_path) + utils.cleanup_staging_area(staging_path) + + try: + _unbundle_iso(sr_path, iso_filename, staging_path) + + # Write Configs + _write_file(os.path.join(staging_path, 'netcfg.ipxe'), + NETCFG_IPXE % {"ip_address": ip_address, + "netmask": netmask, + "gateway": gateway, + "dns": dns, + "boot_menu_url": boot_menu_url}) + + _write_file(os.path.join(staging_path, 'isolinux.cfg'), + ISOLINUX_CFG) + + _create_iso(mkisofs_cmd, iso_filename, staging_path) + finally: + utils.cleanup_staging_area(staging_path) + + +if __name__ == "__main__": + utils.register_plugin_calls(inject) diff --git a/plugins/xenserver/xenapi/etc/xapi.d/plugins/utils.py b/plugins/xenserver/xenapi/etc/xapi.d/plugins/utils.py index 8404fd961b5a..a9002ad2451e 100644 --- a/plugins/xenserver/xenapi/etc/xapi.d/plugins/utils.py +++ b/plugins/xenserver/xenapi/etc/xapi.d/plugins/utils.py @@ -28,6 +28,10 @@ LOG = logging.getLogger(__name__) CHUNK_SIZE = 8192 +class CommandNotFound(Exception): + pass + + def delete_if_exists(path): try: os.unlink(path) @@ -65,7 +69,13 @@ def make_subprocess(cmdline, stdout=False, stderr=False, stdin=False, kwargs['stdin'] = stdin and subprocess.PIPE or None kwargs['universal_newlines'] = universal_newlines kwargs['close_fds'] = close_fds - proc = subprocess.Popen(cmdline, **kwargs) + try: + proc = subprocess.Popen(cmdline, **kwargs) + except OSError, e: + if e.errno == errno.ENOENT: + raise CommandNotFound + else: + raise return proc