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) # (all|some|none). (string value)
#xenapi_torrent_images=none #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 # Options defined in nova.virt.xenapi.vmops

View File

@@ -75,6 +75,7 @@ IMAGE_RAMDISK = '3'
IMAGE_RAW = '4' IMAGE_RAW = '4'
IMAGE_VHD = '5' IMAGE_VHD = '5'
IMAGE_ISO = '6' IMAGE_ISO = '6'
IMAGE_IPXE_ISO = '7'
IMAGE_FIXTURES = { IMAGE_FIXTURES = {
IMAGE_MACHINE: { IMAGE_MACHINE: {
@@ -107,6 +108,12 @@ IMAGE_FIXTURES = {
'disk_format': 'iso', 'disk_format': 'iso',
'container_format': 'bare'}, '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['os_type'])
self.assertTrue(instance['architecture']) 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): def test_spawn_empty_dns(self):
# Test spawning with an empty dns list. # Test spawning with an empty dns list.
self._test_spawn(IMAGE_VHD, None, None, 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.compute import task_states
from nova import exception from nova import exception
from nova.image import glance from nova.image import glance
from nova.network import model as network_model
from nova.openstack.common import excutils from nova.openstack.common import excutils
from nova.openstack.common.gettextutils import _ from nova.openstack.common.gettextutils import _
from nova.openstack.common import importutils from nova.openstack.common import importutils
@@ -97,6 +98,14 @@ xenapi_vm_utils_opts = [
default='none', default='none',
help='Whether or not to download images via Bit Torrent ' help='Whether or not to download images via Bit Torrent '
'(all|some|none).'), '(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 CONF = cfg.CONF
@@ -2265,3 +2274,64 @@ def vm_ref_or_raise(session, instance_name):
if vm_ref is None: if vm_ref is None:
raise exception.InstanceNotFound(instance_id=instance_name) raise exception.InstanceNotFound(instance_id=instance_name)
return vm_ref 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 importutils
from nova.openstack.common import jsonutils from nova.openstack.common import jsonutils
from nova.openstack.common import log as logging from nova.openstack.common import log as logging
from nova.openstack.common import strutils
from nova.openstack.common import timeutils from nova.openstack.common import timeutils
from nova import utils from nova import utils
from nova.virt import configdrive from nova.virt import configdrive
@@ -412,6 +413,20 @@ class VMOps(object):
@step @step
def attach_disks_step(undo_mgr, vm_ref, vdis, disk_image_type): 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, self._attach_disks(instance, vm_ref, name_label, vdis,
disk_image_type, admin_password, disk_image_type, admin_password,
injected_files) injected_files)

View File

@@ -34,6 +34,7 @@ rm -rf $RPM_BUILD_ROOT
/etc/xapi.d/plugins/config_file /etc/xapi.d/plugins/config_file
/etc/xapi.d/plugins/console /etc/xapi.d/plugins/console
/etc/xapi.d/plugins/glance /etc/xapi.d/plugins/glance
/etc/xapi.d/plugins/ipxe
/etc/xapi.d/plugins/kernel /etc/xapi.d/plugins/kernel
/etc/xapi.d/plugins/migration /etc/xapi.d/plugins/migration
/etc/xapi.d/plugins/pluginlib_nova.py /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 CHUNK_SIZE = 8192
class CommandNotFound(Exception):
pass
def delete_if_exists(path): def delete_if_exists(path):
try: try:
os.unlink(path) 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['stdin'] = stdin and subprocess.PIPE or None
kwargs['universal_newlines'] = universal_newlines kwargs['universal_newlines'] = universal_newlines
kwargs['close_fds'] = close_fds kwargs['close_fds'] = close_fds
try:
proc = subprocess.Popen(cmdline, **kwargs) proc = subprocess.Popen(cmdline, **kwargs)
except OSError, e:
if e.errno == errno.ENOENT:
raise CommandNotFound
else:
raise
return proc return proc