# -*- encoding: utf-8 -*- # # Copyright 2013 Hewlett-Packard Development Company, L.P. # # 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. """ PXE Driver and supporting meta-classes. """ import os import shutil from oslo_config import cfg from ironic.common import boot_devices from ironic.common import dhcp_factory from ironic.common import exception from ironic.common.glance_service import service_utils from ironic.common.i18n import _ from ironic.common.i18n import _LE from ironic.common.i18n import _LW from ironic.common import image_service as service from ironic.common import keystone from ironic.common import paths from ironic.common import pxe_utils from ironic.common import states from ironic.common import utils from ironic.conductor import task_manager from ironic.conductor import utils as manager_utils from ironic.drivers import base from ironic.drivers.modules import agent from ironic.drivers.modules import agent_base_vendor from ironic.drivers.modules import deploy_utils from ironic.drivers.modules import image_cache from ironic.drivers.modules import iscsi_deploy from ironic.drivers import utils as driver_utils from ironic.openstack.common import fileutils from ironic.openstack.common import log as logging pxe_opts = [ cfg.StrOpt('pxe_config_template', default=paths.basedir_def( 'drivers/modules/pxe_config.template'), help='Template file for PXE configuration.'), cfg.StrOpt('uefi_pxe_config_template', default=paths.basedir_def( 'drivers/modules/elilo_efi_pxe_config.template'), help='Template file for PXE configuration for UEFI boot' ' loader.'), cfg.StrOpt('tftp_server', default='$my_ip', help='IP address of Ironic compute node\'s tftp server.'), cfg.StrOpt('tftp_root', default='/tftpboot', help='Ironic compute node\'s tftp root path.'), cfg.StrOpt('tftp_master_path', default='/tftpboot/master_images', help='Directory where master tftp images are stored on disk.'), # NOTE(dekehn): Additional boot files options may be created in the event # other architectures require different boot files. cfg.StrOpt('pxe_bootfile_name', default='pxelinux.0', help='Bootfile DHCP parameter.'), cfg.StrOpt('uefi_pxe_bootfile_name', default='elilo.efi', help='Bootfile DHCP parameter for UEFI boot mode.'), cfg.StrOpt('http_url', help='Ironic compute node\'s HTTP server URL. ' 'Example: http://192.1.2.3:8080'), cfg.StrOpt('http_root', default='/httpboot', help='Ironic compute node\'s HTTP root path.'), cfg.BoolOpt('ipxe_enabled', default=False, help='Enable iPXE boot.'), cfg.StrOpt('ipxe_boot_script', default=paths.basedir_def( 'drivers/modules/boot.ipxe'), help='The path to the main iPXE script file.'), ] LOG = logging.getLogger(__name__) CONF = cfg.CONF CONF.register_opts(pxe_opts, group='pxe') CONF.import_opt('deploy_callback_timeout', 'ironic.conductor.manager', group='conductor') REQUIRED_PROPERTIES = { 'deploy_kernel': _("UUID (from Glance) of the deployment kernel. " "Required."), 'deploy_ramdisk': _("UUID (from Glance) of the ramdisk that is " "mounted at boot time. Required."), 'pxe_deploy_kernel': _("DEPRECATED: Use deploy_kernel instead. UUID " "(from Glance) of the deployment kernel. " "Required."), 'pxe_deploy_ramdisk': _("DEPRECATED: Use deploy_ramdisk instead. UUID " "(from Glance) of the ramdisk that is " "mounted at boot time. Required."), } COMMON_PROPERTIES = REQUIRED_PROPERTIES def _parse_driver_info(node): """Gets the driver specific Node deployment info. This method validates whether the 'driver_info' property of the supplied node contains the required information for this driver to deploy images to the node. :param node: a single Node. :returns: A dict with the driver_info values. :raises: MissingParameterValue """ info = node.driver_info d_info = {} # NOTE(lucasagomes): For backwards compatibility let's keep accepting # pxe_deploy_{kernel, ramdisk}, should be removed in Liberty. deprecated_msg = _LW('The "%(old_param)s" parameter is deprecated. ' 'Please update the node %(node)s to use ' '"%(new_param)s" instead.') for parameter in ('deploy_kernel', 'deploy_ramdisk'): value = info.get(parameter) if not value: old_parameter = 'pxe_' + parameter value = info.get(old_parameter) if value: LOG.warning(deprecated_msg, {'old_param': old_parameter, 'new_param': parameter, 'node': node.uuid}) d_info[parameter] = value error_msg = _("Cannot validate PXE bootloader. Some parameters were" " missing in node's driver_info") deploy_utils.check_for_missing_params(d_info, error_msg) return d_info def _parse_deploy_info(node): """Gets the instance and driver specific Node deployment info. This method validates whether the 'instance_info' and 'driver_info' property of the supplied node contains the required information for this driver to deploy images to the node. :param node: a single Node. :returns: A dict with the instance_info and driver_info values. :raises: MissingParameterValue :raises: InvalidParameterValue """ info = {} info.update(iscsi_deploy.parse_instance_info(node)) info.update(_parse_driver_info(node)) return info def _build_pxe_config_options(node, pxe_info, ctx): """Build the PXE config options for a node This method builds the PXE boot options for a node, given all the required parameters. The options should then be passed to pxe_utils.create_pxe_config to create the actual config files. :param node: a single Node. :param pxe_info: a dict of values to set on the configuration file :param ctx: security context :returns: A dictionary of pxe options to be used in the pxe bootfile template. """ is_whole_disk_image = node.driver_internal_info.get('is_whole_disk_image') if is_whole_disk_image: # These are dummy values to satisfy elilo. # image and initrd fields in elilo config cannot be blank. kernel = 'no_kernel' ramdisk = 'no_ramdisk' if CONF.pxe.ipxe_enabled: deploy_kernel = '/'.join([CONF.pxe.http_url, node.uuid, 'deploy_kernel']) deploy_ramdisk = '/'.join([CONF.pxe.http_url, node.uuid, 'deploy_ramdisk']) if not is_whole_disk_image: kernel = '/'.join([CONF.pxe.http_url, node.uuid, 'kernel']) ramdisk = '/'.join([CONF.pxe.http_url, node.uuid, 'ramdisk']) else: deploy_kernel = pxe_info['deploy_kernel'][1] deploy_ramdisk = pxe_info['deploy_ramdisk'][1] if not is_whole_disk_image: kernel = pxe_info['kernel'][1] ramdisk = pxe_info['ramdisk'][1] pxe_options = { 'deployment_aki_path': deploy_kernel, 'deployment_ari_path': deploy_ramdisk, 'pxe_append_params': CONF.pxe.pxe_append_params, 'tftp_server': CONF.pxe.tftp_server, 'aki_path': kernel, 'ari_path': ramdisk } deploy_ramdisk_options = iscsi_deploy.build_deploy_ramdisk_options(node) pxe_options.update(deploy_ramdisk_options) # NOTE(lucasagomes): We are going to extend the normal PXE config # to also contain the agent options so it could be used for both the # DIB ramdisk and the IPA ramdisk agent_opts = agent.build_agent_options(node) pxe_options.update(agent_opts) return pxe_options def _get_token_file_path(node_uuid): """Generate the path for PKI token file.""" return os.path.join(CONF.pxe.tftp_root, 'token-' + node_uuid) def validate_boot_option_for_uefi(node): """In uefi boot mode, validate if the boot option is compatible. This method raises exception if whole disk image being deployed in UEFI boot mode without 'boot_option' being set to 'local'. :param node: a single Node. :raises: InvalidParameterValue """ boot_mode = deploy_utils.get_boot_mode_for_deploy(node) boot_option = iscsi_deploy.get_boot_option(node) if (boot_mode == 'uefi' and node.driver_internal_info.get('is_whole_disk_image') and boot_option != 'local'): LOG.error(_LE("Whole disk image with netboot is not supported in UEFI " "boot mode.")) raise exception.InvalidParameterValue(_( "Conflict: Whole disk image being used for deploy, but " "cannot be used with node %(node_uuid)s configured to use " "UEFI boot with netboot option") % {'node_uuid': node.uuid}) @image_cache.cleanup(priority=25) class TFTPImageCache(image_cache.ImageCache): def __init__(self, image_service=None): super(TFTPImageCache, self).__init__( CONF.pxe.tftp_master_path, # MiB -> B cache_size=CONF.pxe.image_cache_size * 1024 * 1024, # min -> sec cache_ttl=CONF.pxe.image_cache_ttl * 60, image_service=image_service) def _cache_ramdisk_kernel(ctx, node, pxe_info): """Fetch the necessary kernels and ramdisks for the instance.""" fileutils.ensure_tree( os.path.join(pxe_utils.get_root_dir(), node.uuid)) LOG.debug("Fetching necessary kernel and ramdisk for node %s", node.uuid) deploy_utils.fetch_images(ctx, TFTPImageCache(), pxe_info.values(), CONF.force_raw_images) def _get_image_info(node, ctx): """Generate the paths for tftp files for this instance Raises IronicException if - instance does not contain kernel or ramdisk - deploy_kernel or deploy_ramdisk can not be read from driver_info and defaults are not set """ d_info = _parse_deploy_info(node) image_info = {} root_dir = pxe_utils.get_root_dir() image_info.update(pxe_utils.get_deploy_kr_info(node.uuid, d_info)) if node.driver_internal_info.get('is_whole_disk_image'): return image_info i_info = node.instance_info labels = ('kernel', 'ramdisk') if not (i_info.get('kernel') and i_info.get('ramdisk')): glance_service = service.GlanceImageService(version=1, context=ctx) iproperties = glance_service.show(d_info['image_source'])['properties'] for label in labels: i_info[label] = str(iproperties[label + '_id']) node.instance_info = i_info node.save() for label in labels: image_info[label] = ( i_info[label], os.path.join(root_dir, node.uuid, label) ) return image_info def _create_token_file(task): """Save PKI token to file.""" token_file_path = _get_token_file_path(task.node.uuid) token = task.context.auth_token if token: timeout = CONF.conductor.deploy_callback_timeout if timeout and keystone.token_expires_soon(token, timeout): token = keystone.get_admin_auth_token() utils.write_to_file(token_file_path, token) else: utils.unlink_without_raise(token_file_path) def _destroy_token_file(node): """Delete PKI token file.""" token_file_path = _get_token_file_path(node['uuid']) utils.unlink_without_raise(token_file_path) class PXEDeploy(base.DeployInterface): """PXE Deploy Interface for deploy-related actions.""" def get_properties(self): return COMMON_PROPERTIES def validate(self, task): """Validate the deployment information for the task's node. :param task: a TaskManager instance containing the node to act on. :raises: InvalidParameterValue. :raises: MissingParameterValue """ node = task.node # Check the boot_mode and boot_option capabilities values. driver_utils.validate_boot_mode_capability(node) driver_utils.validate_boot_option_capability(node) boot_mode = deploy_utils.get_boot_mode_for_deploy(task.node) if CONF.pxe.ipxe_enabled: if not CONF.pxe.http_url or not CONF.pxe.http_root: raise exception.MissingParameterValue(_( "iPXE boot is enabled but no HTTP URL or HTTP " "root was specified.")) # iPXE and UEFI should not be configured together. if boot_mode == 'uefi': LOG.error(_LE("UEFI boot mode is not supported with " "iPXE boot enabled.")) raise exception.InvalidParameterValue(_( "Conflict: iPXE is enabled, but cannot be used with node" "%(node_uuid)s configured to use UEFI boot") % {'node_uuid': node.uuid}) # Check if 'boot_option' is compatible with 'boot_mode' of uefi and # image being deployed validate_boot_option_for_uefi(task.node) d_info = _parse_deploy_info(node) iscsi_deploy.validate(task) if node.driver_internal_info.get('is_whole_disk_image'): props = [] elif service_utils.is_glance_image(d_info['image_source']): props = ['kernel_id', 'ramdisk_id'] else: props = ['kernel', 'ramdisk'] iscsi_deploy.validate_image_properties(task.context, d_info, props) @task_manager.require_exclusive_lock def deploy(self, task): """Start deployment of the task's node'. Fetches instance image, creates a temporary keystone token file, updates the DHCP port options for next boot, and issues a reboot request to the power driver. This causes the node to boot into the deployment ramdisk and triggers the next phase of PXE-based deployment via VendorPassthru.pass_deploy_info(). :param task: a TaskManager instance containing the node to act on. :returns: deploy state DEPLOYWAIT. """ iscsi_deploy.cache_instance_image(task.context, task.node) iscsi_deploy.check_image_size(task) # TODO(yuriyz): more secure way needed for pass auth token # to deploy ramdisk _create_token_file(task) dhcp_opts = pxe_utils.dhcp_options_for_instance(task) provider = dhcp_factory.DHCPFactory() provider.update_dhcp(task, dhcp_opts) deploy_utils.try_set_boot_device(task, boot_devices.PXE) manager_utils.node_power_action(task, states.REBOOT) return states.DEPLOYWAIT @task_manager.require_exclusive_lock def tear_down(self, task): """Tear down a previous deployment on the task's node. Power off the node. All actual clean-up is done in the clean_up() method which should be called separately. :param task: a TaskManager instance containing the node to act on. :returns: deploy state DELETED. """ manager_utils.node_power_action(task, states.POWER_OFF) return states.DELETED def prepare(self, task): """Prepare the deployment environment for this task's node. Generates the TFTP configuration for PXE-booting both the deployment and user images, fetches the TFTP image from Glance and add it to the local cache. :param task: a TaskManager instance containing the node to act on. """ node = task.node # TODO(deva): optimize this if rerun on existing files if CONF.pxe.ipxe_enabled: # Copy the iPXE boot script to HTTP root directory bootfile_path = os.path.join(CONF.pxe.http_root, os.path.basename(CONF.pxe.ipxe_boot_script)) shutil.copyfile(CONF.pxe.ipxe_boot_script, bootfile_path) pxe_info = _get_image_info(node, task.context) pxe_options = _build_pxe_config_options(node, pxe_info, task.context) if deploy_utils.get_boot_mode_for_deploy(node) == 'uefi': pxe_config_template = CONF.pxe.uefi_pxe_config_template else: pxe_config_template = CONF.pxe.pxe_config_template pxe_utils.create_pxe_config(task, pxe_options, pxe_config_template) # FIXME(lucasagomes): If it's local boot we should not cache # the image kernel and ramdisk (Or even require it). _cache_ramdisk_kernel(task.context, node, pxe_info) # NOTE(deva): prepare may be called from conductor._do_takeover # in which case, it may need to regenerate the PXE config file for an # already-active deployment. if node.provision_state == states.ACTIVE: # this should have been stashed when the deploy was done # but let's guard, just in case it's missing iwdi = node.driver_internal_info.get('is_whole_disk_image') try: root_uuid_or_disk_id = node.driver_internal_info[ 'root_uuid_or_disk_id'] except KeyError: if not iwdi: LOG.warn(_LW("The UUID for the root partition can't be " "found, unable to switch the pxe config from " "deployment mode to service (boot) mode for node " "%(node)s"), {"node": node.uuid}) else: LOG.warn(_LW("The disk id for the whole disk image can't " "be found, unable to switch the pxe config from " "deployment mode to service (boot) mode for " "node %(node)s"), {"node": node.uuid}) else: pxe_config_path = pxe_utils.get_pxe_config_file_path( node.uuid) deploy_utils.switch_pxe_config( pxe_config_path, root_uuid_or_disk_id, deploy_utils.get_boot_mode_for_deploy(node), iwdi) def clean_up(self, task): """Clean up the deployment environment for the task's node. Unlinks TFTP and instance images and triggers image cache cleanup. Removes the TFTP configuration files for this node. As a precaution, this method also ensures the keystone auth token file was removed. :param task: a TaskManager instance containing the node to act on. """ node = task.node try: pxe_info = _get_image_info(node, task.context) except exception.MissingParameterValue as e: LOG.warning(_LW('Could not get image info to clean up images ' 'for node %(node)s: %(err)s'), {'node': node.uuid, 'err': e}) else: for label in pxe_info: path = pxe_info[label][1] utils.unlink_without_raise(path) TFTPImageCache().clean_up() pxe_utils.clean_up_pxe_config(task) iscsi_deploy.destroy_images(node.uuid) _destroy_token_file(node) def take_over(self, task): if not iscsi_deploy.get_boot_option(task.node) == "local": # If it's going to PXE boot we need to update the DHCP server dhcp_opts = pxe_utils.dhcp_options_for_instance(task) provider = dhcp_factory.DHCPFactory() provider.update_dhcp(task, dhcp_opts) else: # If it's going to boot from the local disk, we don't need # PXE config files. They still need to be generated as part # of the prepare() because the deployment does PXE boot the # deploy ramdisk pxe_utils.clean_up_pxe_config(task) class VendorPassthru(agent_base_vendor.BaseAgentVendor): """Interface to mix IPMI and PXE vendor-specific interfaces.""" def get_properties(self): return COMMON_PROPERTIES def validate(self, task, method, **kwargs): """Validates the inputs for a vendor passthru. If invalid, raises an exception; otherwise returns None. Valid methods: * pass_deploy_info * pass_bootloader_install_info :param task: a TaskManager instance containing the node to act on. :param method: method to be validated. :param kwargs: kwargs containins the method's parameters. :raises: InvalidParameterValue if any parameters is invalid. """ if method == 'pass_deploy_info': driver_utils.validate_boot_option_capability(task.node) iscsi_deploy.get_deploy_info(task.node, **kwargs) elif method == 'pass_bootloader_install_info': iscsi_deploy.validate_pass_bootloader_info_input(task, kwargs) @base.passthru(['POST']) @task_manager.require_exclusive_lock def pass_bootloader_install_info(self, task, **kwargs): """Accepts the results of bootloader installation. This method acts as a vendor passthru and accepts the result of the bootloader installation. If bootloader installation was successful, then it notifies the bare metal to proceed to reboot and makes the instance active. If the bootloader installation failed, then it sets provisioning as failed and powers off the node. :param task: A TaskManager object. :param kwargs: The arguments sent with vendor passthru. The expected kwargs are:: 'key': The deploy key for authorization 'status': 'SUCCEEDED' or 'FAILED' 'error': The error message if status == 'FAILED' 'address': The IP address of the ramdisk """ task.process_event('resume') iscsi_deploy.validate_bootloader_install_status(task, kwargs) iscsi_deploy.finish_deploy(task, kwargs['address']) @base.passthru(['POST']) @task_manager.require_exclusive_lock def pass_deploy_info(self, task, **kwargs): """Continues the deployment of baremetal node over iSCSI. This method continues the deployment of the baremetal node over iSCSI from where the deployment ramdisk has left off. :param task: a TaskManager instance containing the node to act on. :param kwargs: kwargs for performing iscsi deployment. :raises: InvalidState """ node = task.node task.process_event('resume') _destroy_token_file(node) is_whole_disk_image = node.driver_internal_info['is_whole_disk_image'] uuid_dict = iscsi_deploy.continue_deploy(task, **kwargs) root_uuid_or_disk_id = uuid_dict.get( 'root uuid', uuid_dict.get('disk identifier')) # save the node's root disk UUID so that another conductor could # rebuild the PXE config file. Due to a shortcoming in Nova objects, # we have to assign to node.driver_internal_info so the node knows it # has changed. driver_internal_info = node.driver_internal_info driver_internal_info['root_uuid_or_disk_id'] = root_uuid_or_disk_id node.driver_internal_info = driver_internal_info node.save() try: if iscsi_deploy.get_boot_option(node) == "local": deploy_utils.try_set_boot_device(task, boot_devices.DISK) # If it's going to boot from the local disk, get rid of # the PXE configuration files used for the deployment pxe_utils.clean_up_pxe_config(task) # Ask the ramdisk to install bootloader and # wait for the call-back through the vendor passthru # 'pass_bootloader_install_info', if it's not a # whole disk image. if not is_whole_disk_image: deploy_utils.notify_ramdisk_to_proceed(kwargs['address']) task.process_event('wait') return else: pxe_config_path = pxe_utils.get_pxe_config_file_path(node.uuid) boot_mode = deploy_utils.get_boot_mode_for_deploy(node) deploy_utils.switch_pxe_config(pxe_config_path, root_uuid_or_disk_id, boot_mode, is_whole_disk_image) except Exception as e: LOG.error(_LE('Deploy failed for instance %(instance)s. ' 'Error: %(error)s'), {'instance': node.instance_uuid, 'error': e}) msg = _('Failed to continue iSCSI deployment.') deploy_utils.set_failed_state(task, msg) else: iscsi_deploy.finish_deploy(task, kwargs.get('address')) @task_manager.require_exclusive_lock def continue_deploy(self, task, **kwargs): """Method invoked when deployed with the IPA ramdisk. This method is invoked during a heartbeat from an agent when the node is in wait-call-back state. This deploys the image on the node and then configures the node to boot according to the desired boot option (netboot or localboot). :param task: a TaskManager object containing the node. :param kwargs: the kwargs passed from the heartbeat method. :raises: InstanceDeployFailure, if it encounters some error during the deploy. """ task.process_event('resume') node = task.node LOG.debug('Continuing the deployment on node %s', node.uuid) # NOTE(lucasagomes): We don't use the token file with the agent, # but as it's created as part of deploy() we are going to remove # it here. _destroy_token_file(node) uuid_dict = iscsi_deploy.do_agent_iscsi_deploy(task, self._client) is_whole_disk_image = node.driver_internal_info['is_whole_disk_image'] if iscsi_deploy.get_boot_option(node) == "local": # Install the boot loader root_uuid = uuid_dict.get('root uuid') efi_sys_uuid = uuid_dict.get('efi system partition uuid') self.configure_local_boot( task, root_uuid=root_uuid, efi_system_part_uuid=efi_sys_uuid) # If it's going to boot from the local disk, get rid of # the PXE configuration files used for the deployment pxe_utils.clean_up_pxe_config(task) else: root_uuid_or_disk_id = uuid_dict.get( 'root uuid', uuid_dict.get('disk identifier')) pxe_config_path = pxe_utils.get_pxe_config_file_path(node.uuid) boot_mode = deploy_utils.get_boot_mode_for_deploy(node) deploy_utils.switch_pxe_config(pxe_config_path, root_uuid_or_disk_id, boot_mode, is_whole_disk_image) self.reboot_and_finish_deploy(task)