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
This commit is contained in:
Josh Kearney 2013-07-18 13:57:54 -05:00 committed by Rick Harris
parent 0ee03b0730
commit 614c4a2359
7 changed files with 294 additions and 1 deletions

View File

@ -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=<None>
# URL to the iPXE boot menu (string value)
#xenapi_ipxe_boot_menu_url=<None>
# 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

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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