diff --git a/doc/source/drivers/ansible.rst b/doc/source/drivers/ansible.rst index e269834..f7700c5 100644 --- a/doc/source/drivers/ansible.rst +++ b/doc/source/drivers/ansible.rst @@ -82,8 +82,11 @@ and partition images, on both ``msdos`` and ``GPT`` labeled disks. Root device hints ~~~~~~~~~~~~~~~~~ -Root device hints are currently not supported (first device returned as -``ansible_devices`` fact is used), but support for them is planned. +Root device hints are currently supported in their basic form only +(with exact matches, without oslo.utils operators). +If no root device hint is provided for the node, first device returned as +part of ``ansible_devices`` fact is used as root device to create partitions +on or write the whole disk image to. Node cleaning ~~~~~~~~~~~~~ diff --git a/ironic_staging_drivers/ansible/deploy.py b/ironic_staging_drivers/ansible/deploy.py index 95d624e..239c608 100644 --- a/ironic_staging_drivers/ansible/deploy.py +++ b/ironic_staging_drivers/ansible/deploy.py @@ -302,6 +302,38 @@ def _parse_partitioning_info(node): return {'partition_info': i_info} +def _parse_root_device_hints(node): + """Convert string with hints to dict. """ + root_device = node.properties.get('root_device') + if not root_device: + return {} + try: + parsed_hints = irlib_utils.parse_root_device_hints(root_device) + except ValueError as e: + raise exception.InvalidParameterValue( + _('Failed to validate the root device hints for node %(node)s. ' + 'Error: %(error)s') % {'node': node.uuid, 'error': e}) + root_device_hints = {} + advanced = {} + for hint, value in parsed_hints.items(): + if isinstance(value, six.string_types): + if value.startswith('== '): + root_device_hints[hint] = int(value[3:]) + elif value.startswith('s== '): + root_device_hints[hint] = urlparse.unquote(value[4:]) + else: + advanced[hint] = value + else: + root_device_hints[hint] = value + if advanced: + raise exception.InvalidParameterValue( + _('Ansible-deploy does not support advanced root device hints ' + 'based on oslo.utils operators. ' + 'Present advanced hints for node %(node)s are %(hints)s.') % { + 'node': node.uuid, 'hints': advanced}) + return root_device_hints + + def _prepare_variables(task): node = task.node i_info = node.instance_info @@ -333,6 +365,11 @@ def _prepare_variables(task): cfgdrv_type = 'file' variables['configdrive'] = {'type': cfgdrv_type, 'location': cfgdrv_location} + + root_device_hints = _parse_root_device_hints(node) + if root_device_hints: + variables['root_device_hints'] = root_device_hints + return variables @@ -441,6 +478,8 @@ class AnsibleDeploy(agent_base.HeartbeatMixin, base.DeployInterface): error_msg = _('Node %s failed to validate deploy image info. Some ' 'parameters were missing') % node.uuid deploy_utils.check_for_missing_params(params, error_msg) + # validate root device hints, proper exceptions are raised from there + _parse_root_device_hints(node) def _ansible_deploy(self, task, node_address): """Internal function for deployment to a node.""" diff --git a/ironic_staging_drivers/ansible/playbooks/library/facts_wwn.py b/ironic_staging_drivers/ansible/playbooks/library/facts_wwn.py new file mode 100644 index 0000000..07d40c4 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/library/facts_wwn.py @@ -0,0 +1,72 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# 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. + +try: + import pyudev + HAS_PYUDEV = True +except ImportError: + HAS_PYUDEV = False + +from ironic_staging_drivers.common.i18n import _LW + + +COLLECT_INFO = (('wwn', 'WWN'), ('serial', 'SERIAL_SHORT'), + ('wwn_with_extension', 'WWN_WITH_EXTENSION'), + ('wwn_vendor_extension', 'WWN_VENDOR_EXTENSION')) + + +def get_devices_wwn(devices): + + if not HAS_PYUDEV: + LOG.warning(_LW('Can not collect "wwn", "wwn_with_extension", ' + '"wwn_vendor_extension" and "serial" when using ' + 'root device hints because there\'s no UDEV python ' + 'binds installed')) + return + + dev_dict = {} + context = pyudev.Context() + for device in devices: + name = '/dev/' + device + try: + udev = pyudev.Device.from_device_file(context, name) + except (ValueError, EnvironmentError, pyudev.DeviceNotFoundError) as e: + LOG.warning(_LW('Device %(dev)s is inaccessible, skipping... ' + 'Error: %(error)s'), {'dev': name, 'error': e}) + continue + + dev_dict[device] = {} + for key, udev_key in COLLECT_INFO: + dev_dict[device][key] = udev.get('ID_%s' % udev_key) + + return {"ansible_facts": {"devices_wwn": dev_dict}} + + +def main(): + module = AnsibleModule( + argument_spec=dict( + devices=dict(required=True, type='list'), + ), + supports_check_mode=True, + ) + + devices = module.params['devices'] + data = get_devices_wwn(devices) + module.exit_json(**data) + + +from ansible.module_utils.basic import * # noqa +if __name__ == '__main__': + main() diff --git a/ironic_staging_drivers/ansible/playbooks/library/root_hints.py b/ironic_staging_drivers/ansible/playbooks/library/root_hints.py new file mode 100644 index 0000000..3fc6569 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/library/root_hints.py @@ -0,0 +1,98 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# 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. + +GIB = 1 << 30 + +EXTRA_PARAMS = set(['wwn', 'serial', 'wwn_with_extension', + 'wwn_vendor_extension']) + + +# NOTE: ansible calculates device size as float with 2-digits precision, +# Ironic requires size in GiB, if we will use ansible size parameter +# a bug is possible for devices > 1 TB +def size_gib(device_info): + sectors = device_info.get('sectors') + sectorsize = device_info.get('sectorsize') + if sectors is None or sectorsize is None: + return '0' + + return str((int(sectors) * int(sectorsize)) // GIB) + + +def merge_devices_info(devices, devices_wwn): + merged_info = devices.copy() + for device in merged_info: + if device in devices_wwn: + merged_info[device].update(devices_wwn[device]) + + # replace size + merged_info[device]['size'] = size_gib(merged_info[device]) + + return merged_info + + +def root_hint(hints, devices): + hint = None + name = hints.pop('name', None) + for device in devices: + for key in hints: + if hints[key] != devices[device].get(key): + break + else: + # If multiple hints are specified, a device must satisfy all + # the hints + dev_name = '/dev/' + device + if name is None or name == dev_name: + hint = dev_name + break + + return hint + + +def main(): + module = AnsibleModule( + argument_spec=dict( + root_device_hints=dict(required=True, type='dict'), + ansible_devices=dict(required=True, type='dict'), + ansible_devices_wwn=dict(required=True, type='dict') + ), + supports_check_mode=True) + + hints = module.params['root_device_hints'] + devices = module.params['ansible_devices'] + devices_wwn = module.params['ansible_devices_wwn'] + + if devices_wwn is None: + extra = set(hints) & EXTRA_PARAMS + if extra: + module.fail_json(msg='Extra hints (supported by additional ansible' + ' module) are set but this information can not be' + ' collected. Extra hints: %s' % ', '.join(extra)) + + devices_info = merge_devices_info(devices, devices_wwn or {}) + hint = root_hint(hints, devices_info) + + if hint is None: + module.fail_json(msg='Root device hints are set, but none of the ' + 'devices satisfy them. Collected devices info: %s' + % devices_info) + + ret_data = {'ansible_facts': {'ironic_root_device': hint}} + module.exit_json(**ret_data) + + +from ansible.module_utils.basic import * # noqa +if __name__ == '__main__': + main() diff --git a/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/main.yaml b/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/main.yaml index 6f88623..8b4d2c3 100644 --- a/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/main.yaml +++ b/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/main.yaml @@ -1,3 +1,6 @@ +- include: roothints.yaml + when: ironic.root_device_hints is defined + - set_fact: ironic_root_device: /dev/{{ ansible_devices.keys()[0] }} when: ironic_root_device is undefined diff --git a/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/roothints.yaml b/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/roothints.yaml new file mode 100644 index 0000000..03ed582 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/discover/tasks/roothints.yaml @@ -0,0 +1,8 @@ +- name: get devices wwn facts + facts_wwn: + devices: "{{ ansible_devices.keys() }}" +- name: calculate root hint + root_hints: + root_device_hints: "{{ ironic.root_device_hints }}" + ansible_devices: "{{ ansible_devices }}" + ansible_devices_wwn: "{{ devices_wwn | default(None) }}" diff --git a/ironic_staging_drivers/tests/unit/ansible/test_deploy.py b/ironic_staging_drivers/tests/unit/ansible/test_deploy.py index a02c29c..d73d123 100644 --- a/ironic_staging_drivers/tests/unit/ansible/test_deploy.py +++ b/ironic_staging_drivers/tests/unit/ansible/test_deploy.py @@ -296,6 +296,37 @@ class TestAnsibleMethods(db_base.DbTestCase): "foo": "bar"}, ansible_deploy._prepare_extra_vars(host_list, ansible_vars)) + def test__parse_root_device_hints(self): + hints = {"wwn": "fake wwn", "size": "12345", "rotational": True} + expected = {"wwn": "fake wwn", "size": 12345, "rotational": True} + props = self.node.properties + props['root_device'] = hints + self.node.properties = props + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual( + expected, ansible_deploy._parse_root_device_hints(task.node)) + + def test__parse_root_device_hints_fail_advanced(self): + hints = {"wwn": "s!= fake wwn", + "size": ">= 12345", + "name": "<or> spam <or> ham", + "rotational": True} + expected = {"wwn": "s!= fake%20wwn", + "name": "<or> spam <or> ham", + "size": ">= 12345"} + props = self.node.properties + props['root_device'] = hints + self.node.properties = props + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + exc = self.assertRaises( + exception.InvalidParameterValue, + ansible_deploy._parse_root_device_hints, task.node) + for key, value in expected.items(): + self.assertIn(six.text_type(key), six.text_type(exc)) + self.assertIn(six.text_type(value), six.text_type(exc)) + @mock.patch.object(ansible_deploy, '_calculate_memory_req', autospec=True, return_value=2000) def test__prepare_variables(self, mem_req_mock): @@ -308,6 +339,23 @@ class TestAnsibleMethods(db_base.DbTestCase): self.assertEqual(expected, ansible_deploy._prepare_variables(task)) + @mock.patch.object(ansible_deploy, '_calculate_memory_req', autospec=True, + return_value=2000) + def test__prepare_variables_root_device_hints(self, mem_req_mock): + props = self.node.properties + props['root_device'] = {"wwn": "fake-wwn"} + self.node.properties = props + self.node.save() + expected = {"image": {"url": "http://image", + "source": "fake-image", + "mem_req": 2000, + "disk_format": "qcow2", + "checksum": "md5:checksum"}, + "root_device_hints": {"wwn": "fake-wwn"}} + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual(expected, + ansible_deploy._prepare_variables(task)) + @mock.patch.object(ansible_deploy, '_calculate_memory_req', autospec=True, return_value=2000) def test__prepare_variables_noglance(self, mem_req_mock): diff --git a/releasenotes/notes/ansible-driver-root-hints-f5ba5796f238e0a9.yaml b/releasenotes/notes/ansible-driver-root-hints-f5ba5796f238e0a9.yaml new file mode 100644 index 0000000..1934538 --- /dev/null +++ b/releasenotes/notes/ansible-driver-root-hints-f5ba5796f238e0a9.yaml @@ -0,0 +1,5 @@ +--- +features: + - Support for basic root device hints added to ansible deploy driver. + Only exact matches are supported for now, advanced hints based on + oslo.utils operators are not supported yet.