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:
parent
0ee03b0730
commit
614c4a2359
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user