diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index a85d5e5ee3..0b12bfe809 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -1113,6 +1113,10 @@ # Template file for PXE configuration. (string value) #pxe_config_template=$pybasedir/drivers/modules/pxe_config.template +# Template file for PXE configuration for UEFI boot loader. +# (string value) +#uefi_pxe_config_template=$pybasedir/drivers/modules/elilo_efi_pxe_config.template + # IP address of Ironic compute node's tftp server. (string # value) #tftp_server=$my_ip @@ -1127,6 +1131,9 @@ # Bootfile DHCP parameter. (string value) #pxe_bootfile_name=pxelinux.0 +# Bootfile DHCP parameter for UEFI boot mode. (string value) +#uefi_pxe_bootfile_name=elilo.efi + # Ironic compute node's HTTP server URL. Example: # http://192.1.2.3:8080 (string value) #http_url= diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 765ae1087c..c60d46577b 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -226,6 +226,14 @@ class FailedToUpdateDHCPOptOnPort(IronicException): message = _("Update DHCP options on port: %(port_id)s failed.") +class FailedToGetIPAddressOnPort(IronicException): + message = _("Retrieve IP address on port: %(port_id)s failed.") + + +class InvalidIPv4Address(IronicException): + message = _("Invalid IPv4 address %(ip_address)s.") + + class FailedToUpdateMacOnPort(IronicException): message = _("Update MAC address on port: %(port_id)s failed.") diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py index a8c4b14e6a..a2b8ea7f61 100644 --- a/ironic/common/pxe_utils.py +++ b/ironic/common/pxe_utils.py @@ -19,11 +19,18 @@ import os import jinja2 from oslo.config import cfg +from ironic.common import dhcp_factory +from ironic.common import exception +from ironic.common import i18n +from ironic.common.i18n import _ from ironic.common import utils from ironic.drivers import utils as driver_utils from ironic.openstack.common import fileutils from ironic.openstack.common import log as logging +_LW = i18n._LW +_LE = i18n._LE + CONF = cfg.CONF LOG = logging.getLogger(__name__) @@ -81,6 +88,29 @@ def _link_mac_pxe_configs(task): utils.create_link_without_raise(pxe_config_file_path, mac_path) +def _link_ip_address_pxe_configs(task): + """Link each IP address with the PXE configuration file. + + :param task: A TaskManager instance. + :raises: FailedToGetIPAddressOnPort + :raises: InvalidIPv4Address + + """ + pxe_config_file_path = get_pxe_config_file_path(task.node.uuid) + + api = dhcp_factory.DHCPFactory().provider + ip_addrs = api.get_ip_addresses(task) + if not ip_addrs: + raise exception.FailedToGetIPAddressOnPort(_( + "Failed to get IP address for any port on node %s.") % + task.node.uuid) + for port_ip_address in ip_addrs: + ip_address_path = _get_pxe_ip_address_path(port_ip_address) + utils.unlink_without_raise(ip_address_path) + utils.create_link_without_raise(pxe_config_file_path, + ip_address_path) + + def _get_pxe_mac_path(mac): """Convert a MAC address into a PXE config file name. @@ -96,6 +126,21 @@ def _get_pxe_mac_path(mac): return os.path.join(get_root_dir(), PXE_CFG_DIR_NAME, mac_file_name) +def _get_pxe_ip_address_path(ip_address): + """Convert an ipv4 address into a PXE config file name. + + :param ip_address: A valid IPv4 address string in the format 'n.n.n.n'. + :returns: the path to the config file. + + """ + ip = ip_address.split('.') + hex_ip = '{0:02X}{1:02X}{2:02X}{3:02X}'.format(*map(int, ip)) + + return os.path.join( + CONF.pxe.tftp_root, hex_ip + ".conf" + ) + + def get_deploy_kr_info(node_uuid, driver_info): """Get uuid and tftp path for deploy kernel and ramdisk. @@ -148,7 +193,11 @@ def create_pxe_config(task, pxe_options, template=None): pxe_config_file_path = get_pxe_config_file_path(task.node.uuid) pxe_config = _build_pxe_config(pxe_options, template) utils.write_to_file(pxe_config_file_path, pxe_config) - _link_mac_pxe_configs(task) + + if get_node_capability(task.node, 'boot_mode') == 'uefi': + _link_ip_address_pxe_configs(task) + else: + _link_mac_pxe_configs(task) def clean_up_pxe_config(task): @@ -159,15 +208,31 @@ def clean_up_pxe_config(task): """ LOG.debug("Cleaning up PXE config for node %s", task.node.uuid) - for mac in driver_utils.get_node_mac_addresses(task): - utils.unlink_without_raise(_get_pxe_mac_path(mac)) + if get_node_capability(task.node, 'boot_mode') == 'uefi': + api = dhcp_factory.DHCPFactory().provider + ip_addresses = api.get_ip_addresses(task) + if not ip_addresses: + return + + for port_ip_address in ip_addresses: + try: + ip_address_path = _get_pxe_ip_address_path(port_ip_address) + except exception.InvalidIPv4Address: + continue + utils.unlink_without_raise(ip_address_path) + else: + for mac in driver_utils.get_node_mac_addresses(task): + utils.unlink_without_raise(_get_pxe_mac_path(mac)) utils.rmtree_without_raise(os.path.join(get_root_dir(), task.node.uuid)) -def dhcp_options_for_instance(): - """Retrieves the DHCP PXE boot options.""" +def dhcp_options_for_instance(task): + """Retrieves the DHCP PXE boot options. + + :param task: A TaskManager instance. + """ dhcp_opts = [] if CONF.pxe.ipxe_enabled: script_name = os.path.basename(CONF.pxe.ipxe_boot_script) @@ -182,11 +247,55 @@ def dhcp_options_for_instance(): dhcp_opts.append({'opt_name': 'bootfile-name', 'opt_value': ipxe_script_url}) else: + if get_node_capability(task.node, 'boot_mode') == 'uefi': + boot_file = CONF.pxe.uefi_pxe_bootfile_name + else: + boot_file = CONF.pxe.pxe_bootfile_name + dhcp_opts.append({'opt_name': 'bootfile-name', - 'opt_value': CONF.pxe.pxe_bootfile_name}) + 'opt_value': boot_file}) dhcp_opts.append({'opt_name': 'server-ip-address', 'opt_value': CONF.pxe.tftp_server}) dhcp_opts.append({'opt_name': 'tftp-server', 'opt_value': CONF.pxe.tftp_server}) return dhcp_opts + + +def get_node_capability(node, capability): + """Returns 'capability' value from node's 'capabilities' property. + + :param node: Node object. + :param capability: Capability key. + :return: Capability value. + If capability is not present, then return "None" + + """ + capabilities = node.properties.get('capabilities') + + if not capabilities: + return + + for node_capability in str(capabilities).split(','): + parts = node_capability.split(':') + if len(parts) == 2 and parts[0] and parts[1]: + if parts[0] == capability: + return parts[1] + else: + LOG.warn(_LW("Ignoring malformed capability '%s'. " + "Format should be 'key:val'."), node_capability) + + +def validate_boot_mode_capability(node): + """Validate the boot_mode capability set in node property. + + :param node: an ironic node object. + :raises: InvalidParameterValue, if 'boot_mode' capability is set + other than 'bios' or 'uefi' or None. + + """ + boot_mode = get_node_capability(node, 'boot_mode') + + if boot_mode and boot_mode not in ['bios', 'uefi']: + raise exception.InvalidParameterValue(_("Invalid boot_mode " + "parameter '%s'.") % boot_mode) diff --git a/ironic/dhcp/base.py b/ironic/dhcp/base.py index 3b30680017..8c15704eca 100644 --- a/ironic/dhcp/base.py +++ b/ironic/dhcp/base.py @@ -67,3 +67,11 @@ class BaseDHCP(object): :raises: FailedToUpdateDHCPOptOnPort """ + + @abc.abstractmethod + def get_ip_addresses(self, task): + """Get IP addresses for all ports in `task`. + + :param task: a TaskManager instance. + :returns: List of IP addresses associated with task.ports + """ diff --git a/ironic/dhcp/neutron.py b/ironic/dhcp/neutron.py index e54066f668..092b718e14 100644 --- a/ironic/dhcp/neutron.py +++ b/ironic/dhcp/neutron.py @@ -25,11 +25,13 @@ from ironic.common import i18n from ironic.common.i18n import _ from ironic.common import keystone from ironic.common import network +from ironic.common import utils from ironic.dhcp import base from ironic.drivers.modules import ssh from ironic.openstack.common import log as logging +_LE = i18n._LE _LW = i18n._LW neutron_opts = [ @@ -174,3 +176,85 @@ class NeutronDHCPApi(base.BaseDHCP): if isinstance(task.driver.power, ssh.SSHPower): LOG.debug("Waiting 15 seconds for Neutron.") time.sleep(15) + + def _get_fixed_ip_address(self, port_id): + """Get a port's fixed ip address. + + :param port_id: Neutron port id. + :returns: Neutron port ip address. + :raises: FailedToGetIPAddressOnPort + :raises: InvalidIPv4Address + """ + ip_address = None + try: + neutron_port = self.client.show_port(port_id).get('port') + except neutron_client_exc.NeutronClientException: + LOG.exception(_LE("Failed to Get IP address on Neutron port %s."), + port_id) + raise exception.FailedToGetIPAddressOnPort(port_id=port_id) + + fixed_ips = neutron_port.get('fixed_ips') + + # NOTE(faizan) At present only the first fixed_ip assigned to this + # neutron port will be used, since nova allocates only one fixed_ip + # for the instance. + if fixed_ips: + ip_address = fixed_ips[0].get('ip_address', None) + + if ip_address: + if utils.is_valid_ipv4(ip_address): + return ip_address + else: + LOG.error(_LE("Neutron returned invalid IPv4 address %s."), + ip_address) + raise exception.InvalidIPv4Address(ip_address=ip_address) + else: + LOG.error(_LE("No IP address assigned to Neutron port %s."), + port_id) + raise exception.FailedToGetIPAddressOnPort(port_id=port_id) + + def _get_port_ip_address(self, task, port_id): + """Get ip address of ironic port assigned by neutron. + + :param task: a TaskManager instance. + :param port_id: ironic Node's port UUID. + :returns: Neutron port ip address associated with Node's port. + :raises: FailedToGetIPAddressOnPort + :raises: InvalidIPv4Address + """ + + vifs = network.get_node_vif_ids(task) + if not vifs: + LOG.warning(_LW("No VIFs found for node %(node)s when attempting " + " to get port IP address."), + {'node': task.node.uuid}) + raise exception.FailedToGetIPAddressOnPort(port_id=port_id) + + port_vif = vifs[port_id] + + port_ip_address = self._get_fixed_ip_address(port_vif) + return port_ip_address + + def get_ip_addresses(self, task): + """Get IP addresses for all ports in `task`. + + :param task: a TaskManager instance. + :returns: List of IP addresses associated with task.ports. + """ + failures = [] + ip_addresses = [] + for port in task.ports: + try: + port_ip_address = self._get_port_ip_address(task, port.uuid) + ip_addresses.append(port_ip_address) + except (exception.FailedToGetIPAddressOnPort, + exception.InvalidIPv4Address): + failures.append(port.uuid) + + if failures: + LOG.warn(_LW("Some errors were encountered on node %(node)s" + " while retrieving IP address on the following" + " ports: %(ports)s."), + {'node': task.node.uuid, 'ports': failures}) + + return ip_addresses diff --git a/ironic/dhcp/none.py b/ironic/dhcp/none.py index eeae828adc..a0da7010d4 100644 --- a/ironic/dhcp/none.py +++ b/ironic/dhcp/none.py @@ -26,3 +26,6 @@ class NoneDHCPApi(base.BaseDHCP): def update_port_address(self, port_id, address): pass + + def get_ip_addresses(self, task): + return [] diff --git a/ironic/drivers/modules/agent.py b/ironic/drivers/modules/agent.py index ba60a3d570..08cfdfd80c 100644 --- a/ironic/drivers/modules/agent.py +++ b/ironic/drivers/modules/agent.py @@ -217,7 +217,7 @@ class AgentDeploy(base.DeployInterface): :param task: a TaskManager instance. :returns: status of the deploy. One of ironic.common.states. """ - dhcp_opts = pxe_utils.dhcp_options_for_instance() + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) provider = dhcp_factory.DHCPFactory(token=task.context.auth_token) provider.update_dhcp(task, dhcp_opts) manager_utils.node_set_boot_device(task, 'pxe', persistent=True) diff --git a/ironic/drivers/modules/deploy_utils.py b/ironic/drivers/modules/deploy_utils.py index 898d211103..7e84f885c9 100644 --- a/ironic/drivers/modules/deploy_utils.py +++ b/ironic/drivers/modules/deploy_utils.py @@ -160,15 +160,21 @@ def block_uuid(dev): return out.strip() -def switch_pxe_config(path, root_uuid): +def switch_pxe_config(path, root_uuid, boot_mode): """Switch a pxe config from deployment mode to service mode.""" with open(path) as f: lines = f.readlines() root = 'UUID=%s' % root_uuid - pxe_cmd = 'goto' if CONF.pxe.ipxe_enabled else 'default' rre = re.compile(r'\{\{ ROOT \}\}') - dre = re.compile('^%s .*$' % pxe_cmd) - boot_line = '%s boot' % pxe_cmd + + if boot_mode == 'uefi': + dre = re.compile('^default=.*$') + boot_line = 'default=boot' + else: + pxe_cmd = 'goto' if CONF.pxe.ipxe_enabled else 'default' + dre = re.compile('^%s .*$' % pxe_cmd) + boot_line = '%s boot' % pxe_cmd + with open(path, 'w') as f: for line in lines: line = rre.sub(root, line) diff --git a/ironic/drivers/modules/elilo_efi_pxe_config.template b/ironic/drivers/modules/elilo_efi_pxe_config.template new file mode 100644 index 0000000000..b4c78d46db --- /dev/null +++ b/ironic/drivers/modules/elilo_efi_pxe_config.template @@ -0,0 +1,11 @@ +default=deploy + +image={{pxe_options.deployment_aki_path}} + label=deploy + initrd={{pxe_options.deployment_ari_path}} + append="rootfstype=ramfs selinux=0 disk={{ pxe_options.disk }} iscsi_target_iqn={{ pxe_options.iscsi_target_iqn }} deployment_id={{ pxe_options.deployment_id }} deployment_key={{ pxe_options.deployment_key }} ironic_api_url={{ pxe_options.ironic_api_url }} troubleshoot=0 text {{ pxe_options.pxe_append_params|default("", true) }} ip=%I:{{pxe_options.tftp_server}}:%G:%M:%H::on" + +image={{pxe_options.aki_path}} + label=boot + initrd={{pxe_options.ari_path}} + append="root={{ ROOT }} ro text {{ pxe_options.pxe_append_params|default("", true) }} ip=%I:{{pxe_options.tftp_server}}:%G:%M:%H::on" diff --git a/ironic/drivers/modules/pxe.py b/ironic/drivers/modules/pxe.py index 60501a2c58..73eff77df5 100644 --- a/ironic/drivers/modules/pxe.py +++ b/ironic/drivers/modules/pxe.py @@ -41,11 +41,19 @@ from ironic.openstack.common import fileutils from ironic.openstack.common import log as logging +_LE = i18n._LE +_LW = i18n._LW + 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.'), @@ -60,6 +68,9 @@ pxe_opts = [ 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'), @@ -168,6 +179,7 @@ def _build_pxe_config_options(node, pxe_info, ctx): 'aki_path': kernel, 'ari_path': ramdisk, 'pxe_append_params': CONF.pxe.pxe_append_params, + 'tftp_server': CONF.pxe.tftp_server } deploy_ramdisk_options = iscsi_deploy.build_deploy_ramdisk_options(node, @@ -265,11 +277,22 @@ class PXEDeploy(base.DeployInterface): :raises: InvalidParameterValue. :raises: MissingParameterValue """ + # Check the boot_mode capability parameter value. + pxe_utils.validate_boot_mode_capability(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 pxe_utils.get_node_capability(task.node, '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': task.node.uuid}) d_info = _parse_deploy_info(task.node) @@ -299,10 +322,25 @@ class PXEDeploy(base.DeployInterface): # 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() + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) provider = dhcp_factory.DHCPFactory(token=task.context.auth_token) provider.update_dhcp(task, dhcp_opts) - manager_utils.node_set_boot_device(task, 'pxe', persistent=True) + + # NOTE(faizan): Under UEFI boot mode, setting of boot device may differ + # between different machines. IPMI does not work for setting boot + # devices in UEFI mode for certain machines. + # Expected IPMI failure for uefi boot mode. Logging a message to + # set the boot device manually and continue with deploy. + try: + manager_utils.node_set_boot_device(task, 'pxe', persistent=True) + except exception.IPMIFailure: + if pxe_utils.get_node_capability(task.node, 'boot_mode') == 'uefi': + LOG.warning(_LW("ipmitool is unable to set boot device while " + "the node is in UEFI boot mode." + "Please set the boot device manually.")) + else: + raise + manager_utils.node_power_action(task, states.REBOOT) return states.DEPLOYWAIT @@ -338,8 +376,14 @@ class PXEDeploy(base.DeployInterface): pxe_info = _get_image_info(task.node, task.context) pxe_options = _build_pxe_config_options(task.node, pxe_info, task.context) + + if pxe_utils.get_node_capability(task.node, 'boot_mode') == '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, - CONF.pxe.pxe_config_template) + pxe_config_template) _cache_ramdisk_kernel(task.context, task.node, pxe_info) def clean_up(self, task): @@ -364,7 +408,7 @@ class PXEDeploy(base.DeployInterface): _destroy_token_file(node) def take_over(self, task): - dhcp_opts = pxe_utils.dhcp_options_for_instance() + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) provider = dhcp_factory.DHCPFactory(token=task.context.auth_token) provider.update_dhcp(task, dhcp_opts) @@ -420,7 +464,8 @@ class VendorPassthru(base.VendorInterface): try: pxe_config_path = pxe_utils.get_pxe_config_file_path(node.uuid) - deploy_utils.switch_pxe_config(pxe_config_path, root_uuid) + deploy_utils.switch_pxe_config(pxe_config_path, root_uuid, + pxe_utils.get_node_capability(node, 'boot_mode')) deploy_utils.notify_deploy_complete(kwargs['address']) diff --git a/ironic/tests/dhcp/test_neutron.py b/ironic/tests/dhcp/test_neutron.py index 1ce4af390f..bb9f4c87aa 100644 --- a/ironic/tests/dhcp/test_neutron.py +++ b/ironic/tests/dhcp/test_neutron.py @@ -21,6 +21,7 @@ from neutronclient.v2_0 import client from ironic.common import dhcp_factory from ironic.common import exception from ironic.common import pxe_utils +from ironic.common import utils from ironic.conductor import task_manager from ironic.dhcp import neutron from ironic.openstack.common import context @@ -179,10 +180,10 @@ class TestNeutron(base.TestCase): @mock.patch('ironic.dhcp.neutron.NeutronDHCPApi.update_port_dhcp_opts') @mock.patch('ironic.common.network.get_node_vif_ids') def test_update_dhcp(self, mock_gnvi, mock_updo): - opts = pxe_utils.dhcp_options_for_instance() mock_gnvi.return_value = {'port-uuid': 'vif-uuid'} with task_manager.acquire(self.context, self.node.uuid) as task: + opts = pxe_utils.dhcp_options_for_instance(task) api = dhcp_factory.DHCPFactory(token=self.context.auth_token) api.update_dhcp(task, self.node) mock_updo.assertCalleOnceWith('vif-uuid', opts) @@ -224,3 +225,124 @@ class TestNeutron(base.TestCase): task, self.node) mock_gnvi.assertCalleOnceWith(task) self.assertEqual(2, mock_updo.call_count) + + @mock.patch.object(client.Client, 'show_port') + @mock.patch.object(client.Client, '__init__') + def test_neutron__get_fixed_ip_address(self, mock_client_init, + mock_show_port): + port_id = 'fake-port-id' + expected = "192.168.1.3" + mock_client_init.return_value = None + api = dhcp_factory.DHCPFactory().provider + port_data = { + "id": port_id, + "network_id": "3cb9bc59-5699-4588-a4b1-b87f96708bc6", + "admin_state_up": True, + "status": "ACTIVE", + "mac_address": "fa:16:3e:4c:2c:30", + "fixed_ips": [ + { + "ip_address": "192.168.1.3", + "subnet_id": "f8a6e8f8-c2ec-497c-9f23-da9616de54ef" + } + ], + "device_id": 'bece68a3-2f8b-4e66-9092-244493d6aba7', + } + port = {'port': port_data} + mock_show_port.return_value = port + result = api._get_fixed_ip_address(port_id) + self.assertEqual(expected, result) + + @mock.patch.object(client.Client, 'show_port') + @mock.patch.object(client.Client, '__init__') + def test_neutron__get_fixed_ip_address_invalid_ip(self, mock_client_init, + mock_show_port): + port_id = 'fake-port-id' + mock_client_init.return_value = None + api = dhcp_factory.DHCPFactory().provider + port_data = { + "id": port_id, + "network_id": "3cb9bc59-5699-4588-a4b1-b87f96708bc6", + "admin_state_up": True, + "status": "ACTIVE", + "mac_address": "fa:16:3e:4c:2c:30", + "fixed_ips": [ + { + "ip_address": "invalid.ip", + "subnet_id": "f8a6e8f8-c2ec-497c-9f23-da9616de54ef" + } + ], + "device_id": 'bece68a3-2f8b-4e66-9092-244493d6aba7', + } + port = {'port': port_data} + mock_show_port.return_value = port + self.assertRaises(exception.InvalidIPv4Address, + api._get_fixed_ip_address, + port_id) + mock_show_port.assert_called_once_with(port_id) + + @mock.patch.object(client.Client, 'show_port') + @mock.patch.object(client.Client, '__init__') + def test_neutron__get_fixed_ip_address_with_exception(self, + mock_client_init, mock_show_port): + port_id = 'fake-port-id' + mock_client_init.return_value = None + api = dhcp_factory.DHCPFactory().provider + + mock_show_port.side_effect = ( + neutron_client_exc.NeutronClientException()) + self.assertRaises(exception.FailedToGetIPAddressOnPort, + api._get_fixed_ip_address, port_id) + mock_show_port.assert_called_once_with(port_id) + + @mock.patch('ironic.dhcp.neutron.NeutronDHCPApi._get_fixed_ip_address') + @mock.patch('ironic.common.network.get_node_vif_ids') + def test__get_port_ip_address(self, mock_gnvi, mock_gfia): + expected = "192.168.1.3" + port = object_utils.create_test_port(self.context, + node_id=self.node.id, + id=6, address='aa:bb:cc', + uuid=utils.generate_uuid(), + extra={'vif_port_id': 'test-vif-A'}, + driver='fake') + mock_gnvi.return_value = {port.uuid: 'vif-uuid'} + mock_gfia.return_value = expected + with task_manager.acquire(self.context, + self.node.uuid) as task: + api = dhcp_factory.DHCPFactory(token=task.context.auth_token) + api = api.provider + result = api._get_port_ip_address(task, port.uuid) + self.assertEqual(expected, result) + + @mock.patch('ironic.dhcp.neutron.NeutronDHCPApi._get_fixed_ip_address') + @mock.patch('ironic.common.network.get_node_vif_ids') + def test__get_port_ip_address_with_exception(self, mock_gnvi, mock_gfia): + expected = "192.168.1.3" + port = object_utils.create_test_port(self.context, + node_id=self.node.id, + id=6, address='aa:bb:cc', + uuid=utils.generate_uuid(), + extra={'vif_port_id': 'test-vif-A'}, + driver='fake') + mock_gnvi.return_value = None + mock_gfia.return_value = expected + with task_manager.acquire(self.context, + self.node.uuid) as task: + api = dhcp_factory.DHCPFactory().provider + self.assertRaises(exception.FailedToGetIPAddressOnPort, + api._get_port_ip_address, task, port) + + @mock.patch('ironic.dhcp.neutron.NeutronDHCPApi._get_port_ip_address') + def test_get_ip_addresses(self, get_ip_mock): + ip_address = '10.10.0.1' + address = "aa:aa:aa:aa:aa:aa" + expected = [ip_address] + object_utils.create_test_port(self.context, node_uuid=self.node.uuid, + address=address) + + get_ip_mock.return_value = ip_address + + with task_manager.acquire(self.context, self.node.uuid) as task: + api = dhcp_factory.DHCPFactory().provider + result = api.get_ip_addresses(task) + self.assertEqual(expected, result) diff --git a/ironic/tests/drivers/test_agent.py b/ironic/tests/drivers/test_agent.py index 504f3ee528..eb48eff0ce 100644 --- a/ironic/tests/drivers/test_agent.py +++ b/ironic/tests/drivers/test_agent.py @@ -57,9 +57,9 @@ class TestAgentDeploy(db_base.DbTestCase): @mock.patch('ironic.conductor.utils.node_set_boot_device') @mock.patch('ironic.conductor.utils.node_power_action') def test_deploy(self, power_mock, bootdev_mock, dhcp_mock): - dhcp_opts = pxe_utils.dhcp_options_for_instance() with task_manager.acquire( self.context, self.node['uuid'], shared=False) as task: + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) driver_return = self.driver.deploy(task) self.assertEqual(driver_return, states.DEPLOYWAIT) dhcp_mock.assert_called_once_with(task, dhcp_opts) diff --git a/ironic/tests/drivers/test_deploy_utils.py b/ironic/tests/drivers/test_deploy_utils.py index b2473c4ee0..db81dd0e0a 100644 --- a/ironic/tests/drivers/test_deploy_utils.py +++ b/ironic/tests/drivers/test_deploy_utils.py @@ -91,6 +91,34 @@ append initrd=ramdisk root=UUID=12345678-1234-1234-1234-1234567890abcdef boot """ +_UEFI_PXECONF_DEPLOY = """ +default=deploy + +image=deploy_kernel + label=deploy + initrd=deploy_ramdisk + append="ro text" + +image=kernel + label=boot + initrd=ramdisk + append="root={{ ROOT }}" +""" + +_UEFI_PXECONF_BOOT = """ +default=boot + +image=deploy_kernel + label=deploy + initrd=deploy_ramdisk + append="ro text" + +image=kernel + label=boot + initrd=ramdisk + append="root=UUID=12345678-1234-1234-1234-1234567890abcdef" +""" + class PhysicalWorkTestCase(tests_base.TestCase): def setUp(self): @@ -392,31 +420,48 @@ class PhysicalWorkTestCase(tests_base.TestCase): class SwitchPxeConfigTestCase(tests_base.TestCase): - def _create_config(self, ipxe=False): + def _create_config(self, ipxe=False, boot_mode=None): (fd, fname) = tempfile.mkstemp() - pxe_cfg = _IPXECONF_DEPLOY if ipxe else _PXECONF_DEPLOY + if boot_mode == 'uefi': + pxe_cfg = _UEFI_PXECONF_DEPLOY + else: + pxe_cfg = _IPXECONF_DEPLOY if ipxe else _PXECONF_DEPLOY os.write(fd, pxe_cfg) os.close(fd) self.addCleanup(os.unlink, fname) return fname def test_switch_pxe_config(self): + boot_mode = 'bios' fname = self._create_config() utils.switch_pxe_config(fname, - '12345678-1234-1234-1234-1234567890abcdef') + '12345678-1234-1234-1234-1234567890abcdef', + boot_mode) with open(fname, 'r') as f: pxeconf = f.read() self.assertEqual(_PXECONF_BOOT, pxeconf) def test_switch_ipxe_config(self): + boot_mode = 'bios' cfg.CONF.set_override('ipxe_enabled', True, 'pxe') fname = self._create_config(ipxe=True) utils.switch_pxe_config(fname, - '12345678-1234-1234-1234-1234567890abcdef') + '12345678-1234-1234-1234-1234567890abcdef', + boot_mode) with open(fname, 'r') as f: pxeconf = f.read() self.assertEqual(_IPXECONF_BOOT, pxeconf) + def test_switch_uefi_pxe_config(self): + boot_mode = 'uefi' + fname = self._create_config(boot_mode=boot_mode) + utils.switch_pxe_config(fname, + '12345678-1234-1234-1234-1234567890abcdef', + boot_mode) + with open(fname, 'r') as f: + pxeconf = f.read() + self.assertEqual(_UEFI_PXECONF_BOOT, pxeconf) + class OtherFunctionTestCase(tests_base.TestCase): def test_get_dev(self): diff --git a/ironic/tests/drivers/test_pxe.py b/ironic/tests/drivers/test_pxe.py index 2b383023ff..c16371424d 100644 --- a/ironic/tests/drivers/test_pxe.py +++ b/ironic/tests/drivers/test_pxe.py @@ -166,6 +166,7 @@ class PXEPrivateMethodsTestCase(db_base.DbTestCase): fake_key = '0123456789ABCDEFGHIJKLMNOPQRSTUV' random_alnum_mock.return_value = fake_key + tftp_server = CONF.pxe.tftp_server if ipxe_enabled: http_url = 'http://192.1.2.3:1234' @@ -201,7 +202,8 @@ class PXEPrivateMethodsTestCase(db_base.DbTestCase): 'deployment_id': u'1be26c0b-03f2-4d2e-ae87-c02d7f33c123', 'ironic_api_url': 'http://192.168.122.184:6385', 'deployment_aki_path': deploy_kernel, - 'disk': 'sda' + 'disk': 'sda', + 'tftp_server': tftp_server } image_info = {'deploy_kernel': ('deploy_kernel', @@ -342,6 +344,30 @@ class PXEDriverTestCase(db_base.DbTestCase): self.assertRaises(exception.MissingParameterValue, task.driver.deploy.validate, task) + @mock.patch.object(base_image_service.BaseImageService, '_show') + def test_validate_fail_invalid_boot_mode(self, mock_glance): + properties = {'capabilities': 'boot_mode:foo,cap2:value2'} + mock_glance.return_value = {'properties': {'kernel_id': 'fake-kernel', + 'ramdisk_id': 'fake-initr'}} + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.node.properties = properties + self.assertRaises(exception.InvalidParameterValue, + task.driver.deploy.validate, task) + + @mock.patch.object(base_image_service.BaseImageService, '_show') + def test_validate_fail_invalid_config_uefi_ipxe(self, mock_glance): + properties = {'capabilities': 'boot_mode:uefi,cap2:value2'} + mock_glance.return_value = {'properties': {'kernel_id': 'fake-kernel', + 'ramdisk_id': 'fake-initr'}} + self.config(ipxe_enabled=True, group='pxe') + self.config(http_url='dummy_url', group='pxe') + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.node.properties = properties + self.assertRaises(exception.InvalidParameterValue, + task.driver.deploy.validate, task) + def test_validate_fail_no_port(self): new_node = obj_utils.create_test_node( self.context, @@ -480,10 +506,10 @@ class PXEDriverTestCase(db_base.DbTestCase): fake_img_path = '/test/path/test.img' mock_get_image_file_path.return_value = fake_img_path mock_get_image_mb.return_value = 1 - dhcp_opts = pxe_utils.dhcp_options_for_instance() with task_manager.acquire(self.context, self.node.uuid, shared=False) as task: + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) state = task.driver.deploy.deploy(task) self.assertEqual(state, states.DEPLOYWAIT) mock_cache_instance_image.assert_called_once_with( @@ -530,9 +556,9 @@ class PXEDriverTestCase(db_base.DbTestCase): @mock.patch.object(dhcp_factory.DHCPFactory, 'update_dhcp') def test_take_over(self, update_dhcp_mock): - dhcp_opts = pxe_utils.dhcp_options_for_instance() with task_manager.acquire( self.context, self.node.uuid, shared=True) as task: + dhcp_opts = pxe_utils.dhcp_options_for_instance(task) task.driver.deploy.take_over(task) update_dhcp_mock.assert_called_once_with( task, dhcp_opts) @@ -548,6 +574,7 @@ class PXEDriverTestCase(db_base.DbTestCase): self.node.save() root_uuid = "12345678-1234-1234-1234-1234567890abcxyz" + boot_mode = None def fake_deploy(**kwargs): return root_uuid @@ -568,7 +595,8 @@ class PXEDriverTestCase(db_base.DbTestCase): mock_image_cache.assert_called_once_with() mock_image_cache.return_value.clean_up.assert_called_once_with() pxe_config_path = pxe_utils.get_pxe_config_file_path(self.node.uuid) - mock_switch_config.assert_called_once_with(pxe_config_path, root_uuid) + mock_switch_config.assert_called_once_with(pxe_config_path, root_uuid, + boot_mode) notify_mock.assert_called_once_with('123456') @mock.patch.object(iscsi_deploy, 'InstanceImageCache') diff --git a/ironic/tests/test_pxe_utils.py b/ironic/tests/test_pxe_utils.py index 46ed42f3b5..4243d5150c 100644 --- a/ironic/tests/test_pxe_utils.py +++ b/ironic/tests/test_pxe_utils.py @@ -19,6 +19,7 @@ import os import mock from oslo.config import cfg +from ironic.common import exception from ironic.common import pxe_utils from ironic.conductor import task_manager from ironic.db import api as dbapi @@ -92,6 +93,27 @@ class TestPXEUtils(db_base.DbTestCase): unlink_mock.assert_has_calls(unlink_calls) create_link_mock.assert_has_calls(create_link_calls) + @mock.patch('ironic.common.utils.create_link_without_raise') + @mock.patch('ironic.common.utils.unlink_without_raise') + @mock.patch('ironic.dhcp.neutron.NeutronDHCPApi.get_ip_addresses') + def test__link_ip_address_pxe_configs(self, get_ip_mock, unlink_mock, + create_link_mock): + ip_address = '10.10.0.1' + address = "aa:aa:aa:aa:aa:aa" + object_utils.create_test_port(self.context, node_uuid=self.node.uuid, + address=address) + + get_ip_mock.return_value = [ip_address] + create_link_calls = [ + mock.call(u'/tftpboot/1be26c0b-03f2-4d2e-ae87-c02d7f33c123/config', + '/tftpboot/0A0A0001.conf'), + ] + with task_manager.acquire(self.context, self.node.uuid) as task: + pxe_utils._link_ip_address_pxe_configs(task) + + unlink_mock.assert_called_once_with('/tftpboot/0A0A0001.conf') + create_link_mock.assert_has_calls(create_link_calls) + @mock.patch('ironic.common.utils.write_to_file') @mock.patch.object(pxe_utils, '_build_pxe_config') @mock.patch('ironic.openstack.common.fileutils.ensure_tree') @@ -139,6 +161,11 @@ class TestPXEUtils(db_base.DbTestCase): self.assertEqual('/httpboot/pxelinux.cfg/00112233aabbcc', pxe_utils._get_pxe_mac_path(mac)) + def test__get_pxe_ip_address_path(self): + ipaddress = '10.10.0.1' + self.assertEqual('/tftpboot/0A0A0001.conf', + pxe_utils._get_pxe_ip_address_path(ipaddress)) + def test_get_root_dir(self): expected_dir = '/tftproot' self.config(ipxe_enabled=False, group='pxe') @@ -167,7 +194,9 @@ class TestPXEUtils(db_base.DbTestCase): {'opt_name': 'tftp-server', 'opt_value': '192.0.2.1'} ] - self.assertEqual(expected_info, pxe_utils.dhcp_options_for_instance()) + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual(expected_info, + pxe_utils.dhcp_options_for_instance(task)) def _test_get_deploy_kr_info(self, expected_dir): node_uuid = 'fake-node' @@ -222,5 +251,56 @@ class TestPXEUtils(db_base.DbTestCase): 'opt_value': '192.0.2.1'}, {'opt_name': 'bootfile-name', 'opt_value': expected_boot_script_url}] - self.assertEqual(sorted(expected_info), - sorted(pxe_utils.dhcp_options_for_instance())) + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual(sorted(expected_info), + sorted(pxe_utils.dhcp_options_for_instance(task))) + + def test_get_node_capability(self): + properties = {'capabilities': 'cap1:value1,cap2:value2'} + self.node.properties = properties + expected = 'value1' + + result = pxe_utils.get_node_capability(self.node, 'cap1') + self.assertEqual(expected, result) + + def test_get_node_capability_returns_none(self): + properties = {'capabilities': 'cap1:value1,cap2:value2'} + self.node.properties = properties + + result = pxe_utils.get_node_capability(self.node, 'capX') + self.assertIsNone(result) + + def test_validate_boot_mode_capability(self): + properties = {'capabilities': 'boot_mode:uefi,cap2:value2'} + self.node.properties = properties + + result = pxe_utils.validate_boot_mode_capability(self.node) + self.assertIsNone(result) + + def test_validate_boot_mode_capability_with_exception(self): + properties = {'capabilities': 'boot_mode:foo,cap2:value2'} + self.node.properties = properties + + self.assertRaises(exception.InvalidParameterValue, + pxe_utils.validate_boot_mode_capability, self.node) + + @mock.patch('ironic.common.utils.rmtree_without_raise', autospec=True) + @mock.patch('ironic.common.utils.unlink_without_raise', autospec=True) + @mock.patch('ironic.dhcp.neutron.NeutronDHCPApi._get_port_ip_address') + def test_clean_up_pxe_config_uefi(self, get_ip_mock, unlink_mock, + rmtree_mock): + ip_address = '10.10.0.1' + address = "aa:aa:aa:aa:aa:aa" + properties = {'capabilities': 'boot_mode:uefi'} + object_utils.create_test_port(self.context, node_uuid=self.node.uuid, + address=address) + + get_ip_mock.return_value = ip_address + + with task_manager.acquire(self.context, self.node.uuid) as task: + task.node.properties = properties + pxe_utils.clean_up_pxe_config(task) + + unlink_mock.assert_called_once_with('/tftpboot/0A0A0001.conf') + rmtree_mock.assert_called_once_with( + os.path.join(CONF.pxe.tftp_root, self.node.uuid))