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:
committed by
Rick Harris
parent
0ee03b0730
commit
614c4a2359
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
134
plugins/xenserver/xenapi/etc/xapi.d/plugins/ipxe
Executable file
134
plugins/xenserver/xenapi/etc/xapi.d/plugins/ipxe
Executable 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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user