diff --git a/doc/source/conf.py b/doc/source/conf.py index 5e64aee492..72d30f8863 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -100,7 +100,9 @@ pygments_style = 'sphinx' # A list of glob-style patterns that should be excluded when looking for # source files. They are matched against the source file names relative to the # source directory, using slashes as directory separators on all platforms. -exclude_patterns = ['api/ironic_tempest_plugin.*'] +exclude_patterns = ['api/ironic_tempest_plugin.*', + 'api/ironic.drivers.modules.ansible.playbooks.*', + 'api/ironic.tests.*'] # Ignore the following warning: WARNING: while setting up extension # wsmeext.sphinxext: directive 'autoattribute' is already registered, diff --git a/driver-requirements.txt b/driver-requirements.txt index a2a46d3e8f..a2c2b8ffc0 100644 --- a/driver-requirements.txt +++ b/driver-requirements.txt @@ -18,3 +18,6 @@ ImcSdk>=0.7.2 # The Redfish hardware type uses the Sushy library sushy + +# Ansible-deploy interface +ansible>=2.4 diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 515d078d12..aae829d4ce 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -813,6 +813,78 @@ #deploy_logs_swift_days_to_expire = 30 +[ansible] + +# +# From ironic +# + +# Extra arguments to pass on every invocation of Ansible. +# (string value) +#ansible_extra_args = + +# Set ansible verbosity level requested when invoking +# "ansible-playbook" command. 4 includes detailed SSH session +# logging. Default is 4 when global debug is enabled and 0 +# otherwise. (integer value) +# Minimum value: 0 +# Maximum value: 4 +#verbosity = + +# Path to "ansible-playbook" script. Default will search the +# $PATH configured for user running ironic-conductor process. +# Provide the full path when ansible-playbook is not in $PATH +# or installed in not default location. (string value) +#ansible_playbook_script = ansible-playbook + +# Path to directory with playbooks, roles and local inventory. +# (string value) +#playbooks_path = $pybasedir/drivers/modules/ansible/playbooks + +# Path to ansible configuration file. If set to empty, system +# default will be used. (string value) +#config_file_path = $pybasedir/drivers/modules/ansible/playbooks/ansible.cfg + +# Number of times to retry getting power state to check if +# bare metal node has been powered off after a soft power off. +# Value of 0 means do not retry on failure. (integer value) +# Minimum value: 0 +#post_deploy_get_power_state_retries = 6 + +# Amount of time (in seconds) to wait between polling power +# state after trigger soft poweroff. (integer value) +# Minimum value: 0 +#post_deploy_get_power_state_retry_interval = 5 + +# Extra amount of memory in MiB expected to be consumed by +# Ansible-related processes on the node. Affects decision +# whether image will fit into RAM. (integer value) +#extra_memory = 10 + +# Skip verifying SSL connections to the image store when +# downloading the image. Setting it to "True" is only +# recommended for testing environments that use self-signed +# certificates. (boolean value) +#image_store_insecure = false + +# Specific CA bundle to use for validating SSL connections to +# the image store. If not specified, CA available in the +# ramdisk will be used. Is not used by default playbooks +# included with the driver. Suitable for environments that use +# self-signed certificates. (string value) +#image_store_cafile = + +# Client cert to use for SSL connections to image store. Is +# not used by default playbooks included with the driver. +# (string value) +#image_store_certfile = + +# Client key to use for SSL connections to image store. Is not +# used by default playbooks included with the driver. (string +# value) +#image_store_keyfile = + + [api] # diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index d6f85efce6..e68bfaf5ed 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -16,6 +16,7 @@ from oslo_config import cfg from ironic.conf import agent +from ironic.conf import ansible from ironic.conf import api from ironic.conf import audit from ironic.conf import cinder @@ -47,6 +48,7 @@ from ironic.conf import swift CONF = cfg.CONF agent.register_opts(CONF) +ansible.register_opts(CONF) api.register_opts(CONF) audit.register_opts(CONF) cinder.register_opts(CONF) diff --git a/ironic/conf/ansible.py b/ironic/conf/ansible.py new file mode 100644 index 0000000000..cfd5162f0c --- /dev/null +++ b/ironic/conf/ansible.py @@ -0,0 +1,96 @@ +# +# 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. + +import os + +from oslo_config import cfg + +from ironic.common.i18n import _ + + +opts = [ + cfg.StrOpt('ansible_extra_args', + help=_('Extra arguments to pass on every ' + 'invocation of Ansible.')), + cfg.IntOpt('verbosity', + min=0, + max=4, + help=_('Set ansible verbosity level requested when invoking ' + '"ansible-playbook" command. ' + '4 includes detailed SSH session logging. ' + 'Default is 4 when global debug is enabled ' + 'and 0 otherwise.')), + cfg.StrOpt('ansible_playbook_script', + default='ansible-playbook', + help=_('Path to "ansible-playbook" script. ' + 'Default will search the $PATH configured for user ' + 'running ironic-conductor process. ' + 'Provide the full path when ansible-playbook is not in ' + '$PATH or installed in not default location.')), + cfg.StrOpt('playbooks_path', + default=os.path.join('$pybasedir', + 'drivers/modules/ansible/playbooks'), + help=_('Path to directory with playbooks, roles and ' + 'local inventory.')), + cfg.StrOpt('config_file_path', + default=os.path.join( + '$pybasedir', + 'drivers/modules/ansible/playbooks/ansible.cfg'), + help=_('Path to ansible configuration file. If set to empty, ' + 'system default will be used.')), + cfg.IntOpt('post_deploy_get_power_state_retries', + min=0, + default=6, + help=_('Number of times to retry getting power state to check ' + 'if bare metal node has been powered off after a soft ' + 'power off. Value of 0 means do not retry on failure.')), + cfg.IntOpt('post_deploy_get_power_state_retry_interval', + min=0, + default=5, + help=_('Amount of time (in seconds) to wait between polling ' + 'power state after trigger soft poweroff.')), + cfg.IntOpt('extra_memory', + default=10, + help=_('Extra amount of memory in MiB expected to be consumed ' + 'by Ansible-related processes on the node. Affects ' + 'decision whether image will fit into RAM.')), + cfg.BoolOpt('image_store_insecure', + default=False, + help=_('Skip verifying SSL connections to the image store ' + 'when downloading the image. ' + 'Setting it to "True" is only recommended for testing ' + 'environments that use self-signed certificates.')), + cfg.StrOpt('image_store_cafile', + help=_('Specific CA bundle to use for validating ' + 'SSL connections to the image store. ' + 'If not specified, CA available in the ramdisk ' + 'will be used. ' + 'Is not used by default playbooks included with ' + 'the driver. ' + 'Suitable for environments that use self-signed ' + 'certificates.')), + cfg.StrOpt('image_store_certfile', + help=_('Client cert to use for SSL connections ' + 'to image store. ' + 'Is not used by default playbooks included with ' + 'the driver.')), + cfg.StrOpt('image_store_keyfile', + help=_('Client key to use for SSL connections ' + 'to image store. ' + 'Is not used by default playbooks included with ' + 'the driver.')), +] + + +def register_opts(conf): + conf.register_opts(opts, group='ansible') diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index b8e2c9d033..34ba57bf10 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -34,6 +34,7 @@ _default_opt_lists = [ _opts = [ ('DEFAULT', itertools.chain(*_default_opt_lists)), ('agent', ironic.conf.agent.opts), + ('ansible', ironic.conf.ansible.opts), ('api', ironic.conf.api.opts), ('audit', ironic.conf.audit.opts), ('cimc', ironic.conf.cisco.cimc_opts), diff --git a/ironic/drivers/generic.py b/ironic/drivers/generic.py index 6e232831b1..a651ac6674 100644 --- a/ironic/drivers/generic.py +++ b/ironic/drivers/generic.py @@ -18,6 +18,7 @@ Generic hardware types. from ironic.drivers import hardware_type from ironic.drivers.modules import agent +from ironic.drivers.modules.ansible import deploy as ansible_deploy from ironic.drivers.modules import fake from ironic.drivers.modules import inspector from ironic.drivers.modules import iscsi_deploy @@ -45,7 +46,8 @@ class GenericHardware(hardware_type.AbstractHardwareType): @property def supported_deploy_interfaces(self): """List of supported deploy interfaces.""" - return [iscsi_deploy.ISCSIDeploy, agent.AgentDeploy] + return [iscsi_deploy.ISCSIDeploy, agent.AgentDeploy, + ansible_deploy.AnsibleDeploy] @property def supported_inspect_interfaces(self): diff --git a/ironic/drivers/modules/ansible/__init__.py b/ironic/drivers/modules/ansible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/drivers/modules/ansible/deploy.py b/ironic/drivers/modules/ansible/deploy.py new file mode 100644 index 0000000000..af30290e29 --- /dev/null +++ b/ironic/drivers/modules/ansible/deploy.py @@ -0,0 +1,619 @@ +# +# 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. + +""" +Ansible deploy interface +""" + +import json +import os +import shlex + +from ironic_lib import metrics_utils +from ironic_lib import utils as irlib_utils +from oslo_concurrency import processutils +from oslo_log import log +from oslo_utils import strutils +from oslo_utils import units +import retrying +import six +import six.moves.urllib.parse as urlparse +import yaml + +from ironic.common import dhcp_factory +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import images +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.conf import CONF +from ironic.drivers import base +from ironic.drivers.modules import agent_base_vendor as agent_base +from ironic.drivers.modules import deploy_utils + + +LOG = log.getLogger(__name__) + +METRICS = metrics_utils.get_metrics_logger(__name__) + +DEFAULT_PLAYBOOKS = { + 'deploy': 'deploy.yaml', + 'shutdown': 'shutdown.yaml', + 'clean': 'clean.yaml' +} +DEFAULT_CLEAN_STEPS = 'clean_steps.yaml' + +OPTIONAL_PROPERTIES = { + 'ansible_deploy_username': _('Deploy ramdisk username for Ansible. ' + 'This user must have passwordless sudo ' + 'permissions. Default is "ansible". ' + 'Optional.'), + 'ansible_deploy_key_file': _('Path to private key file. If not specified, ' + 'default keys for user running ' + 'ironic-conductor process will be used. ' + 'Note that for keys with password, those ' + 'must be pre-loaded into ssh-agent. ' + 'Optional.'), + 'ansible_deploy_playbook': _('Name of the Ansible playbook used for ' + 'deployment. Default is %s. Optional.' + ) % DEFAULT_PLAYBOOKS['deploy'], + 'ansible_shutdown_playbook': _('Name of the Ansible playbook used to ' + 'power off the node in-band. ' + 'Default is %s. Optional.' + ) % DEFAULT_PLAYBOOKS['shutdown'], + 'ansible_clean_playbook': _('Name of the Ansible playbook used for ' + 'cleaning. Default is %s. Optional.' + ) % DEFAULT_PLAYBOOKS['clean'], + 'ansible_clean_steps_config': _('Name of the file with default cleaning ' + 'steps configuration. Default is %s. ' + 'Optional.' + ) % DEFAULT_CLEAN_STEPS +} +COMMON_PROPERTIES = OPTIONAL_PROPERTIES + +INVENTORY_FILE = os.path.join(CONF.ansible.playbooks_path, 'inventory') + + +class PlaybookNotFound(exception.IronicException): + _msg_fmt = _('Failed to set ansible playbook for action %(action)s') + + +def _parse_ansible_driver_info(node, action='deploy'): + user = node.driver_info.get('ansible_deploy_username', 'ansible') + key = node.driver_info.get('ansible_deploy_key_file') + playbook = node.driver_info.get('ansible_%s_playbook' % action, + DEFAULT_PLAYBOOKS.get(action)) + if not playbook: + raise PlaybookNotFound(action=action) + return playbook, user, key + + +def _get_configdrive_path(basename): + return os.path.join(CONF.tempdir, basename + '.cndrive') + + +def _get_node_ip(task): + callback_url = task.node.driver_internal_info.get('agent_url', '') + return urlparse.urlparse(callback_url).netloc.split(':')[0] + + +def _prepare_extra_vars(host_list, variables=None): + nodes_var = [] + for node_uuid, ip, user, extra in host_list: + nodes_var.append(dict(name=node_uuid, ip=ip, user=user, extra=extra)) + extra_vars = dict(nodes=nodes_var) + if variables: + extra_vars.update(variables) + return extra_vars + + +def _run_playbook(name, extra_vars, key, tags=None, notags=None): + """Execute ansible-playbook.""" + playbook = os.path.join(CONF.ansible.playbooks_path, name) + ironic_vars = {'ironic': extra_vars} + args = [CONF.ansible.ansible_playbook_script, playbook, + '-i', INVENTORY_FILE, + '-e', json.dumps(ironic_vars), + ] + + if CONF.ansible.config_file_path: + env = ['env', 'ANSIBLE_CONFIG=%s' % CONF.ansible.config_file_path] + args = env + args + + if tags: + args.append('--tags=%s' % ','.join(tags)) + + if notags: + args.append('--skip-tags=%s' % ','.join(notags)) + + if key: + args.append('--private-key=%s' % key) + + verbosity = CONF.ansible.verbosity + if verbosity is None and CONF.debug: + verbosity = 4 + if verbosity: + args.append('-' + 'v' * verbosity) + + if CONF.ansible.ansible_extra_args: + args.extend(shlex.split(CONF.ansible.ansible_extra_args)) + + try: + out, err = utils.execute(*args) + return out, err + except processutils.ProcessExecutionError as e: + raise exception.InstanceDeployFailure(reason=e) + + +def _calculate_memory_req(task): + image_source = task.node.instance_info['image_source'] + image_size = images.download_size(task.context, image_source) + return image_size // units.Mi + CONF.ansible.extra_memory + + +def _parse_partitioning_info(node): + + info = node.instance_info + i_info = {'label': deploy_utils.get_disk_label(node) or 'msdos'} + is_gpt = i_info['label'] == 'gpt' + unit = 'MiB' + partitions = {} + + def add_partition(name, start, end): + partitions[name] = {'number': len(partitions) + 1, + 'part_start': '%i%s' % (start, unit), + 'part_end': '%i%s' % (end, unit)} + if is_gpt: + partitions[name]['name'] = name + + end = 1 + if is_gpt: + # prepend 1MiB bios_grub partition for GPT so that grub(2) installs + start, end = end, end + 1 + add_partition('bios', start, end) + partitions['bios']['flags'] = ['bios_grub'] + + ephemeral_mb = info['ephemeral_mb'] + if ephemeral_mb: + start, end = end, end + ephemeral_mb + add_partition('ephemeral', start, end) + i_info['ephemeral_format'] = info['ephemeral_format'] + i_info['preserve_ephemeral'] = ( + 'yes' if info['preserve_ephemeral'] else 'no') + + swap_mb = info['swap_mb'] + if swap_mb: + start, end = end, end + swap_mb + add_partition('swap', start, end) + + configdrive = info.get('configdrive') + if configdrive: + # pre-create 64MiB partition for configdrive + start, end = end, end + 64 + add_partition('configdrive', start, end) + + # NOTE(pas-ha) make the root partition last so that + # e.g. cloud-init can grow it on first start + start, end = end, end + info['root_mb'] + add_partition('root', start, end) + if not is_gpt: + partitions['root']['flags'] = ['boot'] + i_info['partitions'] = partitions + 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 _add_ssl_image_options(image): + image['validate_certs'] = ('no' if CONF.ansible.image_store_insecure + else 'yes') + if CONF.ansible.image_store_cafile: + image['cafile'] = CONF.ansible.image_store_cafile + if CONF.ansible.image_store_certfile and CONF.ansible.image_store_keyfile: + image['client_cert'] = CONF.ansible.image_store_certfile + image['client_key'] = CONF.ansible.image_store_keyfile + + +def _prepare_variables(task): + node = task.node + i_info = node.instance_info + image = {} + for i_key, i_value in i_info.items(): + if i_key.startswith('image_'): + image[i_key[6:]] = i_value + image['mem_req'] = _calculate_memory_req(task) + + checksum = image.get('checksum') + if checksum: + # NOTE(pas-ha) checksum can be in : format + # as supported by various Ansible modules, mostly good for + # standalone Ironic case when instance_info is populated manually. + # With no we take that instance_info is populated from Glance, + # where API reports checksum as MD5 always. + if ':' not in checksum: + image['checksum'] = 'md5:%s' % checksum + _add_ssl_image_options(image) + variables = {'image': image} + configdrive = i_info.get('configdrive') + if configdrive: + if urlparse.urlparse(configdrive).scheme in ('http', 'https'): + cfgdrv_type = 'url' + cfgdrv_location = configdrive + else: + cfgdrv_location = _get_configdrive_path(node.uuid) + with open(cfgdrv_location, 'w') as f: + f.write(configdrive) + 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 + + +def _validate_clean_steps(steps, node_uuid): + missing = [] + for step in steps: + name = step.get('name') + if not name: + missing.append({'name': 'undefined', 'field': 'name'}) + continue + if 'interface' not in step: + missing.append({'name': name, 'field': 'interface'}) + args = step.get('args', {}) + for arg_name, arg in args.items(): + if arg.get('required', False) and 'value' not in arg: + missing.append({'name': name, + 'field': '%s.value' % arg_name}) + if missing: + err_string = ', '.join( + 'name %(name)s, field %(field)s' % i for i in missing) + msg = _("Malformed clean_steps file: %s") % err_string + LOG.error(msg) + raise exception.NodeCleaningFailure(node=node_uuid, + reason=msg) + if len(set(s['name'] for s in steps)) != len(steps): + msg = _("Cleaning steps do not have unique names.") + LOG.error(msg) + raise exception.NodeCleaningFailure(node=node_uuid, + reason=msg) + + +def _get_clean_steps(node, interface=None, override_priorities=None): + """Get cleaning steps.""" + clean_steps_file = node.driver_info.get('ansible_clean_steps_config', + DEFAULT_CLEAN_STEPS) + path = os.path.join(CONF.ansible.playbooks_path, clean_steps_file) + try: + with open(path) as f: + internal_steps = yaml.safe_load(f) + except Exception as e: + msg = _('Failed to load clean steps from file ' + '%(file)s: %(exc)s') % {'file': path, 'exc': e} + raise exception.NodeCleaningFailure(node=node.uuid, reason=msg) + + _validate_clean_steps(internal_steps, node.uuid) + + steps = [] + override = override_priorities or {} + for params in internal_steps: + name = params['name'] + clean_if = params['interface'] + if interface is not None and interface != clean_if: + continue + new_priority = override.get(name) + priority = (new_priority if new_priority is not None else + params.get('priority', 0)) + args = {} + argsinfo = params.get('args', {}) + for arg, arg_info in argsinfo.items(): + args[arg] = arg_info.pop('value', None) + step = { + 'interface': clean_if, + 'step': name, + 'priority': priority, + 'abortable': False, + 'argsinfo': argsinfo, + 'args': args + } + steps.append(step) + + return steps + + +class AnsibleDeploy(agent_base.HeartbeatMixin, base.DeployInterface): + """Interface for deploy-related actions.""" + + def __init__(self): + super(AnsibleDeploy, self).__init__() + # NOTE(pas-ha) overriding agent creation as we won't be + # communicating with it, only processing heartbeats + self._client = None + + def get_properties(self): + """Return the properties of the interface.""" + props = COMMON_PROPERTIES.copy() + # NOTE(pas-ha) this is to get the deploy_forces_oob_reboot property + props.update(agent_base.VENDOR_PROPERTIES) + return props + + @METRICS.timer('AnsibleDeploy.validate') + def validate(self, task): + """Validate the driver-specific Node deployment info.""" + task.driver.boot.validate(task) + + node = task.node + iwdi = node.driver_internal_info.get('is_whole_disk_image') + if not iwdi and deploy_utils.get_boot_option(node) == "netboot": + raise exception.InvalidParameterValue(_( + "Node %(node)s is configured to use the %(driver)s driver " + "which does not support netboot.") % {'node': node.uuid, + 'driver': node.driver}) + + params = {} + image_source = node.instance_info.get('image_source') + params['instance_info.image_source'] = image_source + 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.""" + node = task.node + LOG.debug('IP of node %(node)s is %(ip)s', + {'node': node.uuid, 'ip': node_address}) + variables = _prepare_variables(task) + if not node.driver_internal_info.get('is_whole_disk_image'): + variables.update(_parse_partitioning_info(task.node)) + playbook, user, key = _parse_ansible_driver_info(task.node) + node_list = [(node.uuid, node_address, user, node.extra)] + extra_vars = _prepare_extra_vars(node_list, variables=variables) + + LOG.debug('Starting deploy on node %s', node.uuid) + # any caller should manage exceptions raised from here + _run_playbook(playbook, extra_vars, key) + + @METRICS.timer('AnsibleDeploy.deploy') + @task_manager.require_exclusive_lock + def deploy(self, task): + """Perform a deployment to a node.""" + manager_utils.node_power_action(task, states.REBOOT) + return states.DEPLOYWAIT + + @METRICS.timer('AnsibleDeploy.tear_down') + @task_manager.require_exclusive_lock + def tear_down(self, task): + """Tear down a previous deployment on the task's node.""" + manager_utils.node_power_action(task, states.POWER_OFF) + task.driver.network.unconfigure_tenant_networks(task) + return states.DELETED + + @METRICS.timer('AnsibleDeploy.prepare') + def prepare(self, task): + """Prepare the deployment environment for this node.""" + node = task.node + # TODO(pas-ha) investigate takeover scenario + if node.provision_state == states.DEPLOYING: + # adding network-driver dependent provisioning ports + manager_utils.node_power_action(task, states.POWER_OFF) + task.driver.network.add_provisioning_network(task) + if node.provision_state not in [states.ACTIVE, states.ADOPTING]: + node.instance_info = deploy_utils.build_instance_info_for_deploy( + task) + node.save() + boot_opt = deploy_utils.build_agent_options(node) + task.driver.boot.prepare_ramdisk(task, boot_opt) + + @METRICS.timer('AnsibleDeploy.clean_up') + def clean_up(self, task): + """Clean up the deployment environment for this node.""" + task.driver.boot.clean_up_ramdisk(task) + provider = dhcp_factory.DHCPFactory() + provider.clean_dhcp(task) + irlib_utils.unlink_without_raise( + _get_configdrive_path(task.node.uuid)) + + def take_over(self, task): + LOG.error("Ansible deploy does not support take over. " + "You must redeploy the node %s explicitly.", + task.node.uuid) + + def get_clean_steps(self, task): + """Get the list of clean steps from the file. + + :param task: a TaskManager object containing the node + :returns: A list of clean step dictionaries + """ + new_priorities = { + 'erase_devices': CONF.deploy.erase_devices_priority, + 'erase_devices_metadata': + CONF.deploy.erase_devices_metadata_priority + } + return _get_clean_steps(task.node, interface='deploy', + override_priorities=new_priorities) + + @METRICS.timer('AnsibleDeploy.execute_clean_step') + def execute_clean_step(self, task, step): + """Execute a clean step. + + :param task: a TaskManager object containing the node + :param step: a clean step dictionary to execute + :returns: None + """ + node = task.node + playbook, user, key = _parse_ansible_driver_info( + task.node, action='clean') + stepname = step['step'] + + node_address = _get_node_ip(task) + + node_list = [(node.uuid, node_address, user, node.extra)] + extra_vars = _prepare_extra_vars(node_list) + + LOG.debug('Starting cleaning step %(step)s on node %(node)s', + {'node': node.uuid, 'step': stepname}) + step_tags = step['args'].get('tags', []) + try: + _run_playbook(playbook, extra_vars, key, + tags=step_tags) + except exception.InstanceDeployFailure as e: + LOG.error("Ansible failed cleaning step %(step)s " + "on node %(node)s.", + {'node': node.uuid, 'step': stepname}) + manager_utils.cleaning_error_handler(task, six.text_type(e)) + else: + LOG.info('Ansible completed cleaning step %(step)s ' + 'on node %(node)s.', + {'node': node.uuid, 'step': stepname}) + + @METRICS.timer('AnsibleDeploy.prepare_cleaning') + def prepare_cleaning(self, task): + """Boot into the ramdisk to prepare for cleaning. + + :param task: a TaskManager object containing the node + :raises NodeCleaningFailure: if the previous cleaning ports cannot + be removed or if new cleaning ports cannot be created + :returns: None or states.CLEANWAIT for async prepare. + """ + node = task.node + manager_utils.set_node_cleaning_steps(task) + if not node.driver_internal_info['clean_steps']: + # no clean steps configured, nothing to do. + return + task.driver.network.add_cleaning_network(task) + boot_opt = deploy_utils.build_agent_options(node) + task.driver.boot.prepare_ramdisk(task, boot_opt) + manager_utils.node_power_action(task, states.REBOOT) + return states.CLEANWAIT + + @METRICS.timer('AnsibleDeploy.tear_down_cleaning') + def tear_down_cleaning(self, task): + """Clean up the PXE and DHCP files after cleaning. + + :param task: a TaskManager object containing the node + :raises NodeCleaningFailure: if the cleaning ports cannot be + removed + """ + manager_utils.node_power_action(task, states.POWER_OFF) + task.driver.boot.clean_up_ramdisk(task) + task.driver.network.remove_cleaning_network(task) + + @METRICS.timer('AnsibleDeploy.continue_deploy') + def continue_deploy(self, task): + # NOTE(pas-ha) the lock should be already upgraded in heartbeat, + # just setting its purpose for better logging + task.upgrade_lock(purpose='deploy') + task.process_event('resume') + # NOTE(pas-ha) this method is called from heartbeat processing only, + # so we are sure we need this particular method, not the general one + node_address = _get_node_ip(task) + self._ansible_deploy(task, node_address) + self.reboot_to_instance(task) + + @METRICS.timer('AnsibleDeploy.reboot_to_instance') + def reboot_to_instance(self, task): + node = task.node + LOG.info('Ansible complete deploy on node %s', node.uuid) + + LOG.debug('Rebooting node %s to instance', node.uuid) + manager_utils.node_set_boot_device(task, 'disk', persistent=True) + self.reboot_and_finish_deploy(task) + task.driver.boot.clean_up_ramdisk(task) + + @METRICS.timer('AnsibleDeploy.reboot_and_finish_deploy') + def reboot_and_finish_deploy(self, task): + wait = CONF.ansible.post_deploy_get_power_state_retry_interval * 1000 + attempts = CONF.ansible.post_deploy_get_power_state_retries + 1 + + @retrying.retry( + stop_max_attempt_number=attempts, + retry_on_result=lambda state: state != states.POWER_OFF, + wait_fixed=wait + ) + def _wait_until_powered_off(task): + return task.driver.power.get_power_state(task) + + node = task.node + oob_power_off = strutils.bool_from_string( + node.driver_info.get('deploy_forces_oob_reboot', False)) + try: + if not oob_power_off: + try: + node_address = _get_node_ip(task) + playbook, user, key = _parse_ansible_driver_info( + node, action='shutdown') + node_list = [(node.uuid, node_address, user, node.extra)] + extra_vars = _prepare_extra_vars(node_list) + _run_playbook(playbook, extra_vars, key) + _wait_until_powered_off(task) + except Exception as e: + LOG.warning('Failed to soft power off node %(node_uuid)s ' + 'in at least %(timeout)d seconds. ' + 'Error: %(error)s', + {'node_uuid': node.uuid, + 'timeout': (wait * (attempts - 1)) / 1000, + 'error': e}) + # NOTE(pas-ha) flush is a part of deploy playbook + # so if it finished successfully we can safely + # power off the node out-of-band + manager_utils.node_power_action(task, states.POWER_OFF) + else: + manager_utils.node_power_action(task, states.POWER_OFF) + task.driver.network.remove_provisioning_network(task) + task.driver.network.configure_tenant_networks(task) + manager_utils.node_power_action(task, states.POWER_ON) + except Exception as e: + msg = (_('Error rebooting node %(node)s after deploy. ' + 'Error: %(error)s') % + {'node': node.uuid, 'error': e}) + agent_base.log_and_raise_deployment_error(task, msg) + + task.process_event('done') + LOG.info('Deployment to node %s done', task.node.uuid) diff --git a/ironic/drivers/modules/ansible/playbooks/add-ironic-nodes.yaml b/ironic/drivers/modules/ansible/playbooks/add-ironic-nodes.yaml new file mode 100644 index 0000000000..568ff28301 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/add-ironic-nodes.yaml @@ -0,0 +1,11 @@ +- hosts: conductor + gather_facts: no + tasks: + - add_host: + group: ironic + hostname: "{{ item.name }}" + ansible_host: "{{ item.ip }}" + ansible_user: "{{ item.user }}" + ironic_extra: "{{ item.extra | default({}) }}" + with_items: "{{ ironic.nodes }}" + tags: always diff --git a/ironic/drivers/modules/ansible/playbooks/ansible.cfg b/ironic/drivers/modules/ansible/playbooks/ansible.cfg new file mode 100644 index 0000000000..cd524cd33a --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/ansible.cfg @@ -0,0 +1,35 @@ +[defaults] +# retries through the ansible-deploy driver are not supported +retry_files_enabled = False + +# this is using supplied callback_plugin to interleave ansible event logs +# into Ironic-conductor log as set in ironic configuration file, +# see callback_plugin/ironic_log.ini for some options to set +# (DevStack _needs_ some tweaks) +callback_whitelist = ironic_log + +# For better security, bake SSH host keys into bootstrap image, +# add those to ~/.ssh/known_hosts for user running ironic-conductor service +# on all nodes where ironic-conductor and ansible-deploy driver are installed, +# and set the host_key_checking to True (or comment it out, it is the default) +host_key_checking = False + +# uncomment if you have problem with ramdisk locale on ansible >= 2.1 +#module_set_locale=False + +# This sets the interval (in seconds) of Ansible internal processes polling +# each other. Lower values improve performance with large playbooks at +# the expense of extra CPU load. Higher values are more suitable for Ansible +# usage in automation scenarios, when UI responsiveness is not required but +# CPU usage might be a concern. +# Default corresponds to the value hardcoded in Ansible ≤ 2.1: +#internal_poll_interval = 0.001 + +[ssh_connection] +# pipelining greatly increases speed of deployment, disable it only when +# your version of ssh client on ironic node or server in bootstrap image +# do not support it or if you can not disable "requiretty" for the +# passwordless sudoer user in the bootstrap image. +# See Ansible documentation for more info: +# http://docs.ansible.com/ansible/intro_configuration.html#pipelining +pipelining = True diff --git a/ironic/drivers/modules/ansible/playbooks/callback_plugins/ironic_log.ini b/ironic/drivers/modules/ansible/playbooks/callback_plugins/ironic_log.ini new file mode 100644 index 0000000000..4d10933985 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/callback_plugins/ironic_log.ini @@ -0,0 +1,15 @@ +[ironic] +# If Ironic's config is not in one of default oslo_config locations, +# specify the path to it here +#config_file = + +# Force usage of journald +#use_journal = True + +# Force usage of syslog +#use_syslog = False + +# Force usage of given file to log to. +# Useful for a testing system with only stderr logging +# (e.g. DevStack deployed w/o systemd) +#log_file = diff --git a/ironic/drivers/modules/ansible/playbooks/callback_plugins/ironic_log.py b/ironic/drivers/modules/ansible/playbooks/callback_plugins/ironic_log.py new file mode 100644 index 0000000000..55fa5b8344 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/callback_plugins/ironic_log.py @@ -0,0 +1,148 @@ +# +# 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. + +import ConfigParser +import os + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import strutils +import pbr.version + + +CONF = cfg.CONF +DOMAIN = 'ironic' +VERSION = pbr.version.VersionInfo(DOMAIN).release_string() + + +# find and parse callback config file +def parse_callback_config(): + basename = os.path.splitext(__file__)[0] + config = ConfigParser.ConfigParser() + callback_config = {'ironic_config': None, + 'ironic_log_file': None, + 'use_journal': True, + 'use_syslog': False} + try: + config.readfp(open(basename + ".ini")) + if config.has_option('ironic', 'config_file'): + callback_config['ironic_config'] = config.get( + 'ironic', 'config_file') + if config.has_option('ironic', 'log_file'): + callback_config['ironic_log_file'] = config.get( + 'ironic', 'log_file') + if config.has_option('ironic', 'use_journal'): + callback_config['use_journal'] = strutils.bool_from_string( + config.get('ironic', 'use_journal')) + if config.has_option('ironic', 'use_syslog'): + callback_config['use_syslog'] = strutils.bool_from_string( + config.get('ironic', 'use_syslog')) + except Exception: + pass + return callback_config + + +def setup_log(): + + logging.register_options(CONF) + + conf_kwargs = dict(args=[], project=DOMAIN, version=VERSION) + callback_config = parse_callback_config() + + if callback_config['ironic_config']: + conf_kwargs['default_config_files'] = [ + callback_config['ironic_config']] + CONF(**conf_kwargs) + + if callback_config['use_journal']: + CONF.set_override('use_journal', True) + if callback_config['use_syslog']: + CONF.set_override('use_syslog', True) + if callback_config['ironic_log_file']: + CONF.set_override("log_file", callback_config['ironic_log_file']) + + logging.setup(CONF, DOMAIN) + + +class CallbackModule(object): + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'notification' + CALLBACK_NAME = 'ironic_log' + CALLBACK_NEEDS_WHITELIST = True + + def __init__(self, display=None): + setup_log() + self.log = logging.getLogger(__name__) + self.node = None + self.opts = {} + + # NOTE(pas-ha) this method is required for Ansible>=2.4 + # TODO(pas-ha) rewrite to support defining callback plugin options + # in ansible.cfg after we require Ansible >=2.4 + def set_options(self, options): + self.opts = options + + def runner_msg_dict(self, result): + self.node = result._host.get_name() + name = result._task.get_name() + res = str(result._result) + return dict(node=self.node, name=name, res=res) + + def v2_playbook_on_task_start(self, task, is_conditional): + # NOTE(pas-ha) I do not know (yet) how to obtain a ref to host + # until first task is processed + node = self.node or "Node" + name = task.get_name() + if name == 'setup': + self.log.debug("Processing task %(name)s.", dict(name=name)) + else: + self.log.debug("Processing task %(name)s on node %(node)s.", + dict(name=name, node=node)) + + def v2_runner_on_failed(self, result, *args, **kwargs): + self.log.error( + "Ansible task %(name)s failed on node %(node)s: %(res)s", + self.runner_msg_dict(result)) + + def v2_runner_on_ok(self, result): + msg_dict = self.runner_msg_dict(result) + if msg_dict['name'] == 'setup': + self.log.info("Ansible task 'setup' complete on node %(node)s", + msg_dict) + else: + self.log.info("Ansible task %(name)s complete on node %(node)s: " + "%(res)s", msg_dict) + + def v2_runner_on_unreachable(self, result): + self.log.error( + "Node %(node)s was unreachable for Ansible task %(name)s: %(res)s", + self.runner_msg_dict(result)) + + def v2_runner_on_async_poll(self, result): + self.log.debug("Polled ansible task %(name)s for complete " + "on node %(node)s: %(res)s", + self.runner_msg_dict(result)) + + def v2_runner_on_async_ok(self, result): + self.log.info("Async Ansible task %(name)s complete on node %(node)s: " + "%(res)s", self.runner_msg_dict(result)) + + def v2_runner_on_async_failed(self, result): + self.log.error("Async Ansible task %(name)s failed on node %(node)s: " + "%(res)s", self.runner_msg_dict(result)) + + def v2_runner_on_skipped(self, result): + self.log.debug( + "Ansible task %(name)s skipped on node %(node)s: %(res)s", + self.runner_msg_dict(result)) diff --git a/ironic/drivers/modules/ansible/playbooks/clean.yaml b/ironic/drivers/modules/ansible/playbooks/clean.yaml new file mode 100644 index 0000000000..04645cdc11 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/clean.yaml @@ -0,0 +1,6 @@ +--- +- import_playbook: add-ironic-nodes.yaml + +- hosts: ironic + roles: + - clean diff --git a/ironic/drivers/modules/ansible/playbooks/clean_steps.yaml b/ironic/drivers/modules/ansible/playbooks/clean_steps.yaml new file mode 100644 index 0000000000..b404819933 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/clean_steps.yaml @@ -0,0 +1,19 @@ +- name: erase_devices_metadata + priority: 99 + interface: deploy + args: + tags: + required: true + description: list of playbook tags used to erase partition table on disk devices + value: + - zap + +- name: erase_devices + priority: 10 + interface: deploy + args: + tags: + required: true + description: list of playbook tags used to erase disk devices + value: + - shred diff --git a/ironic/drivers/modules/ansible/playbooks/deploy.yaml b/ironic/drivers/modules/ansible/playbooks/deploy.yaml new file mode 100644 index 0000000000..3fbb60d228 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/deploy.yaml @@ -0,0 +1,12 @@ +--- +- import_playbook: add-ironic-nodes.yaml + +- hosts: ironic + roles: + - discover + - prepare + - deploy + - configure + post_tasks: + - name: flush disk state + command: sync diff --git a/ironic/drivers/modules/ansible/playbooks/inventory b/ironic/drivers/modules/ansible/playbooks/inventory new file mode 100644 index 0000000000..f6599ef67b --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/inventory @@ -0,0 +1 @@ +conductor ansible_connection=local diff --git a/ironic/drivers/modules/ansible/playbooks/library/facts_wwn.py b/ironic/drivers/modules/ansible/playbooks/library/facts_wwn.py new file mode 100644 index 0000000000..7703f570c1 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/library/facts_wwn.py @@ -0,0 +1,64 @@ +# -*- 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. + +COLLECT_INFO = (('wwn', 'WWN'), ('serial', 'SERIAL_SHORT'), + ('wwn_with_extension', 'WWN_WITH_EXTENSION'), + ('wwn_vendor_extension', 'WWN_VENDOR_EXTENSION')) + + +def get_devices_wwn(devices, module): + try: + import pyudev + # NOTE(pas-ha) creating context might fail if udev is missing + context = pyudev.Context() + except ImportError: + module.warn('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 = {} + for device in devices: + name = '/dev/' + device + try: + udev = pyudev.Device.from_device_file(context, name) + except (ValueError, EnvironmentError, pyudev.DeviceNotFoundError) as e: + module.warn('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) + module.exit_json(**data) + + +from ansible.module_utils.basic import * # noqa +if __name__ == '__main__': + main() diff --git a/ironic/drivers/modules/ansible/playbooks/library/root_hints.py b/ironic/drivers/modules/ansible/playbooks/library/root_hints.py new file mode 100644 index 0000000000..32473eb77b --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/library/root_hints.py @@ -0,0 +1,97 @@ +# -*- 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 not devices_wwn: + 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/drivers/modules/ansible/playbooks/library/stream_url.py b/ironic/drivers/modules/ansible/playbooks/library/stream_url.py new file mode 100644 index 0000000000..dd750d6378 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/library/stream_url.py @@ -0,0 +1,118 @@ +#!/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. + +import hashlib +import string + +import requests + +# adapted from IPA +DEFAULT_CHUNK_SIZE = 1024 * 1024 # 1MB + + +class StreamingDownloader(object): + + def __init__(self, url, chunksize, hash_algo=None, verify=True, + certs=None): + if hash_algo is not None: + self.hasher = hashlib.new(hash_algo) + else: + self.hasher = None + self.chunksize = chunksize + resp = requests.get(url, stream=True, verify=verify, certs=certs) + if resp.status_code != 200: + raise Exception('Invalid response code: %s' % resp.status_code) + + self._request = resp + + def __iter__(self): + for chunk in self._request.iter_content(chunk_size=self.chunksize): + if self.hasher is not None: + self.hasher.update(chunk) + yield chunk + + def checksum(self): + if self.hasher is not None: + return self.hasher.hexdigest() + + +def stream_to_dest(url, dest, chunksize, hash_algo, verify=True, certs=None): + downloader = StreamingDownloader(url, chunksize, hash_algo, + verify=verify, certs=certs) + + with open(dest, 'wb+') as f: + for chunk in downloader: + f.write(chunk) + + return downloader.checksum() + + +def main(): + module = AnsibleModule( + argument_spec=dict( + url=dict(required=True, type='str'), + dest=dict(required=True, type='str'), + checksum=dict(required=False, type='str', default=''), + chunksize=dict(required=False, type='int', + default=DEFAULT_CHUNK_SIZE), + validate_certs=dict(required=False, type='bool', default=True), + client_cert=dict(required=False, type='str', default=''), + client_key=dict(required=False, type='str', default='') + + )) + + url = module.params['url'] + dest = module.params['dest'] + checksum = module.params['checksum'] + chunksize = module.params['chunksize'] + validate = module.params['validate_certs'] + client_cert = module.params['client_cert'] + client_key = module.params['client_key'] + if client_cert: + certs = (client_cert, client_key) if client_key else client_cert + else: + certs = None + + if checksum == '': + hash_algo, checksum = None, None + else: + try: + hash_algo, checksum = checksum.rsplit(':', 1) + except ValueError: + module.fail_json(msg='The checksum parameter has to be in format ' + '":"') + checksum = checksum.lower() + if not all(c in string.hexdigits for c in checksum): + module.fail_json(msg='The checksum must be valid HEX number') + + if hash_algo not in hashlib.algorithms_available: + module.fail_json(msg="%s checksums are not supported" % hash_algo) + + try: + actual_checksum = stream_to_dest( + url, dest, chunksize, hash_algo, verify=validate, certs=certs) + except Exception as e: + module.fail_json(msg=str(e)) + else: + if hash_algo and actual_checksum != checksum: + module.fail_json(msg='Invalid dest checksum') + else: + module.exit_json(changed=True) + + +# NOTE(pas-ha) Ansible's module_utils.basic is licensed under BSD (2 clause) +from ansible.module_utils.basic import * # noqa +if __name__ == '__main__': + main() diff --git a/ironic/drivers/modules/ansible/playbooks/roles/clean/defaults/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/clean/defaults/main.yaml new file mode 100644 index 0000000000..2250254875 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/clean/defaults/main.yaml @@ -0,0 +1 @@ +sectors_to_wipe: 1024 diff --git a/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/main.yaml new file mode 100644 index 0000000000..587b8d2776 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/main.yaml @@ -0,0 +1,6 @@ +- import_tasks: zap.yaml + tags: + - zap +- import_tasks: shred.yaml + tags: + - shred diff --git a/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/shred.yaml b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/shred.yaml new file mode 100644 index 0000000000..511229064d --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/shred.yaml @@ -0,0 +1,8 @@ +- name: clean block devices + become: yes + command: shred -f -z /dev/{{ item.key }} + async: 3600 + poll: 30 + with_dict: "{{ ansible_devices }}" + when: + - item.value.host diff --git a/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/wipe.yaml b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/wipe.yaml new file mode 100644 index 0000000000..877f8f3dfe --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/wipe.yaml @@ -0,0 +1,24 @@ +- name: store start and end of disk + set_fact: + start_sectors: + - 0 + end_sectors: + - "{{ (device.value.sectors | int) - sectors_to_wipe }}" + when: + - device.value.host + +- name: update start and end sectors with such for partitions + set_fact: + start_sectors: "{{ start_sectors + [item.value.start | int ] }}" + end_sectors: "{{ end_sectors + [ (item.value.start | int) + ( item.value.sectors | int) - sectors_to_wipe ] }}" + with_dict: "{{ device.value.partitions }}" + when: + - device.value.host + +- name: wipe starts and ends of disks and partitions + command: dd if=/dev/zero of=/dev/{{ device.key }} ibs={{ device.value.sectorsize }} obs={{ device.value.sectorsize }} count={{ sectors_to_wipe }} seek={{ item }} + with_flattened: + - "{{ start_sectors | map('int') | list | sort (reverse=True) }}" + - "{{ end_sectors | map('int') | list | sort (reverse=True) }}" + when: + - device.value.host diff --git a/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/zap.yaml b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/zap.yaml new file mode 100644 index 0000000000..d406d4daf7 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/clean/tasks/zap.yaml @@ -0,0 +1,16 @@ +# NOTE(pas-ha) this is to ensure that partition metadata that might be stored +# in the start or end of partiton itself also becomes unusable +# and does not interfere with future partition scheme if new partitions +# happen to fall on the same boundaries where old partitions were. +# NOTE(pas-ha) loop_control works with Ansible >= 2.1 +- include_tasks: wipe.yaml + with_dict: "{{ ansible_devices }}" + loop_control: + loop_var: device + +- name: wipe general partition table metadata + become: yes + command: sgdisk -Z /dev/{{ item.key }} + with_dict: "{{ ansible_devices }}" + when: + - item.value.host diff --git a/ironic/drivers/modules/ansible/playbooks/roles/configure/defaults/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/configure/defaults/main.yaml new file mode 100644 index 0000000000..9fdad71fb2 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/configure/defaults/main.yaml @@ -0,0 +1 @@ +tmp_rootfs_mount: /tmp/rootfs diff --git a/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/grub.yaml b/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/grub.yaml new file mode 100644 index 0000000000..2c40e81647 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/grub.yaml @@ -0,0 +1,79 @@ +- name: discover grub-install command + find: + paths: + - "{{ tmp_rootfs_mount }}/usr/sbin" + pattern: "grub*-install" + register: grub_install_found + +- name: discover grub-mkconfig command + find: + paths: + - "{{ tmp_rootfs_mount }}/usr/sbin" + pattern: "grub*-mkconfig" + register: grub_config_found + +- name: find grub config file + find: + paths: + - "{{ tmp_rootfs_mount }}/boot" + pattern: "grub*.cfg" + recurse: yes + register: grub_file_found + +- name: test if all needed grub files were found + assert: + that: + - "{{ grub_install_found.matched > 0 }}" + - "{{ grub_config_found.matched > 0 }}" + - "{{ grub_file_found.matched > 0 }}" + +- name: set paths to grub commands + set_fact: + grub_install_cmd: "{{ grub_install_found.files[0].path | replace(tmp_rootfs_mount,'') }}" + grub_config_cmd: "{{ grub_config_found.files[0].path | replace(tmp_rootfs_mount,'') }}" + grub_config_file: "{{ grub_file_found.files[0].path | replace(tmp_rootfs_mount,'') }}" + +- name: make dirs for chroot + become: yes + file: + state: directory + path: "{{ tmp_rootfs_mount }}/{{ item }}" + with_items: + - dev + - sys + - proc + +- name: mount dirs for chroot + become: yes + command: mount -o bind /{{ item }} {{ tmp_rootfs_mount }}/{{ item }} + with_items: + - dev + - sys + - proc + +- block: + - name: get grub version string + become: yes + command: chroot {{ tmp_rootfs_mount }} /bin/sh -c '{{ grub_install_cmd }} --version' + register: grub_version_string + - name: install grub to disk + become: yes + command: chroot {{ tmp_rootfs_mount }} /bin/sh -c '{{ grub_install_cmd }} {{ ironic_root_device }}' + - name: preload lvm modules for grub2 + become: yes + lineinfile: + dest: "{{ tmp_rootfs_mount }}/etc/default/grub" + state: present + line: GRUB_PRELOAD_MODULES=lvm + when: grub_version_string.stdout.split() | last | first == '2' + - name: create grub config + become: yes + command: chroot {{ tmp_rootfs_mount }} /bin/sh -c '{{ grub_config_cmd }} -o {{ grub_config_file }}' + always: + - name: unmount dirs for chroot + become: yes + command: umount {{ tmp_rootfs_mount }}/{{ item }} + with_items: + - dev + - sys + - proc diff --git a/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/main.yaml new file mode 100644 index 0000000000..9baa882a60 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/main.yaml @@ -0,0 +1,4 @@ +- import_tasks: mounts.yaml + when: ironic.image.type | default('whole-disk-image') == 'partition' +- import_tasks: grub.yaml + when: ironic.image.type | default('whole-disk-image') == 'partition' diff --git a/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/mounts.yaml b/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/mounts.yaml new file mode 100644 index 0000000000..870fa9af84 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/configure/tasks/mounts.yaml @@ -0,0 +1,8 @@ +- name: create tmp mount point for root + file: + state: directory + path: "{{ tmp_rootfs_mount }}" + +- name: mount user image root + become: yes + command: mount {{ ironic_image_target }} {{ tmp_rootfs_mount }} diff --git a/ironic/drivers/modules/ansible/playbooks/roles/deploy/files/partition_configdrive.sh b/ironic/drivers/modules/ansible/playbooks/roles/deploy/files/partition_configdrive.sh new file mode 100755 index 0000000000..056a8152c2 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/deploy/files/partition_configdrive.sh @@ -0,0 +1,110 @@ +#!/bin/sh + +# Copyright 2013 Rackspace, Inc. +# +# 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. + +# NOTE(pas-ha) this is mostly copied over from Ironic Python Agent +# compared to the original file in IPA, + +# TODO(pas-ha) rewrite this shell script to be a proper Ansible module + +# This should work with almost any image that uses MBR partitioning and +# doesn't already have 3 or more partitions -- or else you'll no longer +# be able to create extended partitions on the disk. + +# Takes one argument - block device + +log() { + echo "`basename $0`: $@" +} + +fail() { + log "Error: $@" + exit 1 +} + +MAX_DISK_PARTITIONS=128 +MAX_MBR_SIZE_MB=2097152 + +DEVICE="$1" + +[ -b $DEVICE ] || fail "(DEVICE) $DEVICE is not a block device" + +# We need to run partx -u to ensure all partitions are visible so the +# following blkid command returns partitions just imaged to the device +partx -u $DEVICE || fail "running partx -u $DEVICE" + +# todo(jayf): partx -u doesn't work in all cases, but partprobe fails in +# devstack. We run both commands now as a temporary workaround for bug 1433812 +# long term, this should all be refactored into python and share code with +# the other partition-modifying code in the agent. +partprobe $DEVICE || true + +# Check for preexisting partition for configdrive +EXISTING_PARTITION=`/sbin/blkid -l -o device $DEVICE -t LABEL=config-2` +if [ -z $EXISTING_PARTITION ]; then + # Check if it is GPT partition and needs to be re-sized + if [ `partprobe $DEVICE print 2>&1 | grep "fix the GPT to use all of the space"` ]; then + log "Fixing GPT to use all of the space on device $DEVICE" + sgdisk -e $DEVICE || fail "move backup GPT data structures to the end of ${DEVICE}" + + # Need to create new partition for config drive + # Not all images have partion numbers in a sequential numbers. There are holes. + # These holes get filled up when a new partition is created. + TEMP_DIR="$(mktemp -d)" + EXISTING_PARTITION_LIST=$TEMP_DIR/existing_partitions + UPDATED_PARTITION_LIST=$TEMP_DIR/updated_partitions + + gdisk -l $DEVICE | grep -A$MAX_DISK_PARTITIONS "Number Start" | grep -v "Number Start" > $EXISTING_PARTITION_LIST + + # Create small partition at the end of the device + log "Adding configdrive partition to $DEVICE" + sgdisk -n 0:-64MB:0 $DEVICE || fail "creating configdrive on ${DEVICE}" + + gdisk -l $DEVICE | grep -A$MAX_DISK_PARTITIONS "Number Start" | grep -v "Number Start" > $UPDATED_PARTITION_LIST + + CONFIG_PARTITION_ID=`diff $EXISTING_PARTITION_LIST $UPDATED_PARTITION_LIST | tail -n1 |awk '{print $2}'` + ISO_PARTITION="${DEVICE}${CONFIG_PARTITION_ID}" + else + log "Working on MBR only device $DEVICE" + + # get total disk size, to detect if that exceeds 2TB msdos limit + disksize_bytes=$(blockdev --getsize64 $DEVICE) + disksize_mb=$(( ${disksize_bytes%% *} / 1024 / 1024)) + + startlimit=-64MiB + endlimit=-0 + if [ "$disksize_mb" -gt "$MAX_MBR_SIZE_MB" ]; then + # Create small partition at 2TB limit + startlimit=$(($MAX_MBR_SIZE_MB - 65)) + endlimit=$(($MAX_MBR_SIZE_MB - 1)) + fi + + log "Adding configdrive partition to $DEVICE" + parted -a optimal -s -- $DEVICE mkpart primary fat32 $startlimit $endlimit || fail "creating configdrive on ${DEVICE}" + + # Find partition we just created + # Dump all partitions, ignore empty ones, then get the last partition ID + ISO_PARTITION=`sfdisk --dump $DEVICE | grep -v ' 0,' | tail -n1 | awk -F ':' '{print $1}' | sed -e 's/\s*$//'` || fail "finding ISO partition created on ${DEVICE}" + + # Wait for udev to pick up the partition + udevadm settle --exit-if-exists=$ISO_PARTITION + fi +else + log "Existing configdrive found on ${DEVICE} at ${EXISTING_PARTITION}" + ISO_PARTITION=$EXISTING_PARTITION +fi + +# Output the created/discovered partition for configdrive +echo "configdrive $ISO_PARTITION" diff --git a/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/configdrive.yaml b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/configdrive.yaml new file mode 100644 index 0000000000..702797b643 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/configdrive.yaml @@ -0,0 +1,44 @@ +- name: download configdrive data + get_url: + url: "{{ ironic.configdrive.location }}" + dest: /tmp/{{ inventory_hostname }}.gz.base64 + validate_certs: "{{ ironic.image.validate_certs|default(omit) }}" + async: 600 + poll: 15 + when: ironic.configdrive.type|default('') == 'url' + +- block: + - name: copy configdrive file to node + copy: + src: "{{ ironic.configdrive.location }}" + dest: /tmp/{{ inventory_hostname }}.gz.base64 + - name: remove configdrive from conductor + delegate_to: conductor + file: + path: "{{ ironic.configdrive.location }}" + state: absent + when: ironic.configdrive.type|default('') == 'file' + +- name: unpack configdrive + shell: cat /tmp/{{ inventory_hostname }}.gz.base64 | base64 --decode | gunzip > /tmp/{{ inventory_hostname }}.cndrive + +- block: + - name: prepare config drive partition + become: yes + script: partition_configdrive.sh {{ ironic_root_device }} + register: configdrive_partition_output + + - name: test the output of configdrive partitioner + assert: + that: + - "{{ (configdrive_partition_output.stdout_lines | last).split() | length == 2 }}" + - "{{ (configdrive_partition_output.stdout_lines | last).split() | first == 'configdrive' }}" + + - name: store configdrive partition + set_fact: + ironic_configdrive_target: "{{ (configdrive_partition_output.stdout_lines | last).split() | last }}" + when: ironic_configdrive_target is undefined + +- name: write configdrive + become: yes + command: dd if=/tmp/{{ inventory_hostname }}.cndrive of={{ ironic_configdrive_target }} bs=64K oflag=direct diff --git a/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/download.yaml b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/download.yaml new file mode 100644 index 0000000000..87f2501db4 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/download.yaml @@ -0,0 +1,13 @@ +- name: check that downloaded image will fit into memory + assert: + that: "{{ ansible_memfree_mb }} >= {{ ironic.image.mem_req }}" + msg: "The image size is too big, no free memory available" + +- name: download image with checksum validation + get_url: + url: "{{ ironic.image.url }}" + dest: /tmp/{{ inventory_hostname }}.img + checksum: "{{ ironic.image.checksum|default(omit) }}" + validate_certs: "{{ ironic.image.validate_certs|default(omit) }}" + async: 600 + poll: 15 diff --git a/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/main.yaml new file mode 100644 index 0000000000..235a4711cc --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/main.yaml @@ -0,0 +1,7 @@ +- import_tasks: download.yaml + when: ironic.image.disk_format != 'raw' + +- import_tasks: write.yaml + +- import_tasks: configdrive.yaml + when: ironic.configdrive is defined diff --git a/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/write.yaml b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/write.yaml new file mode 100644 index 0000000000..ed0cc85b67 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/deploy/tasks/write.yaml @@ -0,0 +1,20 @@ +- name: convert and write + become: yes + command: qemu-img convert -t directsync -O host_device /tmp/{{ inventory_hostname }}.img {{ ironic_image_target }} + async: 1200 + poll: 10 + when: ironic.image.disk_format != 'raw' + +- name: stream to target + become: yes + stream_url: + url: "{{ ironic.image.url }}" + dest: "{{ ironic_image_target }}" + checksum: "{{ ironic.image.checksum|default(omit) }}" + validate_certs: "{{ ironic.image.validate_certs|default(omit) }}" + async: 600 + poll: 15 + when: ironic.image.disk_format == 'raw' + +- name: flush + command: sync diff --git a/ironic/drivers/modules/ansible/playbooks/roles/discover/tasks/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/discover/tasks/main.yaml new file mode 100644 index 0000000000..f80d5b545e --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/discover/tasks/main.yaml @@ -0,0 +1,13 @@ +- import_tasks: roothints.yaml + when: ironic.root_device_hints is defined + +- set_fact: + ironic_root_device: /dev/{{ item.key }} + with_dict: "{{ ansible_devices }}" + when: + - ironic_root_device is undefined + - item.value.host + +- set_fact: + ironic_image_target: "{{ ironic_root_device }}" + when: ironic_image_target is undefined diff --git a/ironic/drivers/modules/ansible/playbooks/roles/discover/tasks/roothints.yaml b/ironic/drivers/modules/ansible/playbooks/roles/discover/tasks/roothints.yaml new file mode 100644 index 0000000000..488a218132 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/discover/tasks/roothints.yaml @@ -0,0 +1,9 @@ +- 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({}) }}" diff --git a/ironic/drivers/modules/ansible/playbooks/roles/prepare/tasks/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/prepare/tasks/main.yaml new file mode 100644 index 0000000000..e92aba69d5 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/prepare/tasks/main.yaml @@ -0,0 +1,2 @@ +- import_tasks: parted.yaml + when: ironic.image.type | default('whole-disk-image') == 'partition' diff --git a/ironic/drivers/modules/ansible/playbooks/roles/prepare/tasks/parted.yaml b/ironic/drivers/modules/ansible/playbooks/roles/prepare/tasks/parted.yaml new file mode 100644 index 0000000000..9dab1218b2 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/prepare/tasks/parted.yaml @@ -0,0 +1,45 @@ +# this is to handle no autocleaning in ironic +- name: erase partition table + become: yes + command: dd if=/dev/zero of={{ ironic_root_device }} bs=512 count=36 + when: not ironic.partition_info.preserve_ephemeral|default('no')|bool + +- name: run parted + become: yes + parted: + device: "{{ ironic_root_device }}" + label: "{{ ironic.partition_info.label }}" + state: "{{ item.1.state | default('present') }}" + name: "{{ item.1.name | default(omit) }}" + number: "{{ item.1.number }}" + part_type: "{{ item.1.part_type | default(omit) }}" + part_start: "{{ item.1.part_start }}" + part_end: "{{ item.1.part_end }}" + flags: "{{ item.1.flags | default(omit) }}" + align: "{{ item.1.align | default(omit) }}" + unit: "{{ item.1.unit | default(omit) }}" + with_items: + - "{{ ironic.partition_info.partitions.items() | sort(attribute='1.number') }}" + +- name: reset image target to root partition + set_fact: + ironic_image_target: "{{ ironic_root_device }}{{ ironic.partition_info.partitions.root.number }}" + +- name: make swap + become: yes + command: mkswap -L swap1 "{{ ironic_root_device }}{{ ironic.partition_info.partitions.swap.number }}" + when: ironic.partition_info.partitions.swap is defined + +- name: format ephemeral partition + become: yes + filesystem: + dev: "{{ ironic_root_device }}{{ ironic.partition_info.partitions.ephemeral.number }}" + fstype: "{{ ironic.partition_info.ephemeral_format }}" + force: yes + opts: "-L ephemeral0" + when: ironic.partition_info.partitions.ephemeral is defined and not ironic.partition_info.preserve_ephemeral|default('no')|bool + +- name: save block device for configdrive if partition was created + set_fact: + ironic_configdrive_target: "{{ ironic_root_device }}{{ ironic.partition_info.partitions.configdrive.number }}" + when: ironic.partition_info.partitions.configdrive is defined diff --git a/ironic/drivers/modules/ansible/playbooks/roles/shutdown/tasks/main.yaml b/ironic/drivers/modules/ansible/playbooks/roles/shutdown/tasks/main.yaml new file mode 100644 index 0000000000..3172f5d3a4 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/roles/shutdown/tasks/main.yaml @@ -0,0 +1,6 @@ +- name: soft power off + become: yes + shell: sleep 5 && poweroff + async: 1 + poll: 0 + ignore_errors: true diff --git a/ironic/drivers/modules/ansible/playbooks/shutdown.yaml b/ironic/drivers/modules/ansible/playbooks/shutdown.yaml new file mode 100644 index 0000000000..f8b84f7591 --- /dev/null +++ b/ironic/drivers/modules/ansible/playbooks/shutdown.yaml @@ -0,0 +1,6 @@ +--- +- import_playbook: add-ironic-nodes.yaml + +- hosts: ironic + roles: + - shutdown diff --git a/ironic/tests/unit/drivers/modules/ansible/__init__.py b/ironic/tests/unit/drivers/modules/ansible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/drivers/modules/ansible/test_deploy.py b/ironic/tests/unit/drivers/modules/ansible/test_deploy.py new file mode 100644 index 0000000000..f70c765f12 --- /dev/null +++ b/ironic/tests/unit/drivers/modules/ansible/test_deploy.py @@ -0,0 +1,870 @@ +# 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. + +from ironic_lib import utils as irlib_utils +import mock +from oslo_concurrency import processutils +import six + +from ironic.common import exception +from ironic.common import states +from ironic.common import utils as com_utils +from ironic.conductor import task_manager +from ironic.conductor import utils +from ironic.drivers.modules.ansible import deploy as ansible_deploy +from ironic.drivers.modules import deploy_utils +from ironic.drivers.modules import fake +from ironic.drivers.modules import pxe +from ironic.tests.unit.conductor import mgr_utils +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as object_utils + +INSTANCE_INFO = { + 'image_source': 'fake-image', + 'image_url': 'http://image', + 'image_checksum': 'checksum', + 'image_disk_format': 'qcow2', + 'root_mb': 5120, + 'swap_mb': 0, + 'ephemeral_mb': 0 +} + +DRIVER_INFO = { + 'deploy_kernel': 'glance://deploy_kernel_uuid', + 'deploy_ramdisk': 'glance://deploy_ramdisk_uuid', + 'ansible_deploy_username': 'test', + 'ansible_deploy_key_file': '/path/key', + 'ipmi_address': '127.0.0.1', +} +DRIVER_INTERNAL_INFO = { + 'is_whole_disk_image': True, + 'clean_steps': [] +} + + +class AnsibleDeployTestCaseBase(db_base.DbTestCase): + + def setUp(self): + super(AnsibleDeployTestCaseBase, self).setUp() + self.config(enabled_deploy_interfaces='direct,iscsi,ansible') + mgr_utils.mock_the_extension_manager(driver='ipmi', + namespace='ironic.hardware.types') + node = { + 'driver': 'ipmi', + 'deploy_interface': 'ansible', + 'instance_info': INSTANCE_INFO, + 'driver_info': DRIVER_INFO, + 'driver_internal_info': DRIVER_INTERNAL_INFO, + } + self.node = object_utils.create_test_node(self.context, **node) + + +class TestAnsibleMethods(AnsibleDeployTestCaseBase): + + def test__parse_ansible_driver_info(self): + playbook, user, key = ansible_deploy._parse_ansible_driver_info( + self.node, 'deploy') + self.assertEqual(ansible_deploy.DEFAULT_PLAYBOOKS['deploy'], playbook) + self.assertEqual('test', user) + self.assertEqual('/path/key', key) + + def test__parse_ansible_driver_info_no_playbook(self): + self.assertRaises(exception.IronicException, + ansible_deploy._parse_ansible_driver_info, + self.node, 'test') + + def test__get_node_ip(self): + di_info = self.node.driver_internal_info + di_info['agent_url'] = 'http://1.2.3.4:5678' + self.node.driver_internal_info = di_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual('1.2.3.4', + ansible_deploy._get_node_ip(task)) + + @mock.patch.object(com_utils, 'execute', return_value=('out', 'err'), + autospec=True) + def test__run_playbook(self, execute_mock): + self.config(group='ansible', playbooks_path='/path/to/playbooks') + self.config(group='ansible', config_file_path='/path/to/config') + self.config(group='ansible', verbosity=3) + self.config(group='ansible', ansible_extra_args='--timeout=100') + extra_vars = {'foo': 'bar'} + + ansible_deploy._run_playbook('deploy', extra_vars, '/path/to/key', + tags=['spam'], notags=['ham']) + + execute_mock.assert_called_once_with( + 'env', 'ANSIBLE_CONFIG=/path/to/config', + 'ansible-playbook', '/path/to/playbooks/deploy', '-i', + ansible_deploy.INVENTORY_FILE, '-e', '{"ironic": {"foo": "bar"}}', + '--tags=spam', '--skip-tags=ham', + '--private-key=/path/to/key', '-vvv', '--timeout=100') + + @mock.patch.object(com_utils, 'execute', return_value=('out', 'err'), + autospec=True) + def test__run_playbook_default_verbosity_nodebug(self, execute_mock): + self.config(group='ansible', playbooks_path='/path/to/playbooks') + self.config(group='ansible', config_file_path='/path/to/config') + self.config(debug=False) + extra_vars = {'foo': 'bar'} + + ansible_deploy._run_playbook('deploy', extra_vars, '/path/to/key') + + execute_mock.assert_called_once_with( + 'env', 'ANSIBLE_CONFIG=/path/to/config', + 'ansible-playbook', '/path/to/playbooks/deploy', '-i', + ansible_deploy.INVENTORY_FILE, '-e', '{"ironic": {"foo": "bar"}}', + '--private-key=/path/to/key') + + @mock.patch.object(com_utils, 'execute', return_value=('out', 'err'), + autospec=True) + def test__run_playbook_default_verbosity_debug(self, execute_mock): + self.config(group='ansible', playbooks_path='/path/to/playbooks') + self.config(group='ansible', config_file_path='/path/to/config') + self.config(debug=True) + extra_vars = {'foo': 'bar'} + + ansible_deploy._run_playbook('deploy', extra_vars, '/path/to/key') + + execute_mock.assert_called_once_with( + 'env', 'ANSIBLE_CONFIG=/path/to/config', + 'ansible-playbook', '/path/to/playbooks/deploy', '-i', + ansible_deploy.INVENTORY_FILE, '-e', '{"ironic": {"foo": "bar"}}', + '--private-key=/path/to/key', '-vvvv') + + @mock.patch.object(com_utils, 'execute', + side_effect=processutils.ProcessExecutionError( + description='VIKINGS!'), + autospec=True) + def test__run_playbook_fail(self, execute_mock): + self.config(group='ansible', playbooks_path='/path/to/playbooks') + self.config(group='ansible', config_file_path='/path/to/config') + self.config(debug=False) + extra_vars = {'foo': 'bar'} + + exc = self.assertRaises(exception.InstanceDeployFailure, + ansible_deploy._run_playbook, + 'deploy', extra_vars, '/path/to/key') + self.assertIn('VIKINGS!', six.text_type(exc)) + execute_mock.assert_called_once_with( + 'env', 'ANSIBLE_CONFIG=/path/to/config', + 'ansible-playbook', '/path/to/playbooks/deploy', '-i', + ansible_deploy.INVENTORY_FILE, '-e', '{"ironic": {"foo": "bar"}}', + '--private-key=/path/to/key') + + def test__parse_partitioning_info_root_msdos(self): + expected_info = { + 'partition_info': { + 'label': 'msdos', + 'partitions': { + 'root': + {'number': 1, + 'part_start': '1MiB', + 'part_end': '5121MiB', + 'flags': ['boot']} + }}} + + i_info = ansible_deploy._parse_partitioning_info(self.node) + + self.assertEqual(expected_info, i_info) + + def test__parse_partitioning_info_all_gpt(self): + in_info = dict(INSTANCE_INFO) + in_info['swap_mb'] = 128 + in_info['ephemeral_mb'] = 256 + in_info['ephemeral_format'] = 'ext4' + in_info['preserve_ephemeral'] = True + in_info['configdrive'] = 'some-fake-user-data' + in_info['capabilities'] = {'disk_label': 'gpt'} + self.node.instance_info = in_info + self.node.save() + + expected_info = { + 'partition_info': { + 'label': 'gpt', + 'ephemeral_format': 'ext4', + 'preserve_ephemeral': 'yes', + 'partitions': { + 'bios': + {'number': 1, + 'name': 'bios', + 'part_start': '1MiB', + 'part_end': '2MiB', + 'flags': ['bios_grub']}, + 'ephemeral': + {'number': 2, + 'part_start': '2MiB', + 'part_end': '258MiB', + 'name': 'ephemeral'}, + 'swap': + {'number': 3, + 'part_start': '258MiB', + 'part_end': '386MiB', + 'name': 'swap'}, + 'configdrive': + {'number': 4, + 'part_start': '386MiB', + 'part_end': '450MiB', + 'name': 'configdrive'}, + 'root': + {'number': 5, + 'part_start': '450MiB', + 'part_end': '5570MiB', + 'name': 'root'} + }}} + + i_info = ansible_deploy._parse_partitioning_info(self.node) + + self.assertEqual(expected_info, i_info) + + @mock.patch.object(ansible_deploy.images, 'download_size', autospec=True) + def test__calculate_memory_req(self, image_mock): + self.config(group='ansible', extra_memory=1) + image_mock.return_value = 2000000 # < 2MiB + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual(2, ansible_deploy._calculate_memory_req(task)) + image_mock.assert_called_once_with(task.context, 'fake-image') + + def test__get_configdrive_path(self): + self.config(tempdir='/path/to/tmpdir') + self.assertEqual('/path/to/tmpdir/spam.cndrive', + ansible_deploy._get_configdrive_path('spam')) + + def test__prepare_extra_vars(self): + host_list = [('fake-uuid', '1.2.3.4', 'spam', 'ham'), + ('other-uuid', '5.6.7.8', 'eggs', 'vikings')] + ansible_vars = {"foo": "bar"} + self.assertEqual( + {"nodes": [ + {"name": "fake-uuid", "ip": '1.2.3.4', + "user": "spam", "extra": "ham"}, + {"name": "other-uuid", "ip": '5.6.7.8', + "user": "eggs", "extra": "vikings"}], + "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": " spam ham", + "rotational": True} + expected = {"wwn": "s!= fake%20wwn", + "name": " spam 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): + expected = {"image": {"url": "http://image", + "validate_certs": "yes", + "source": "fake-image", + "mem_req": 2000, + "disk_format": "qcow2", + "checksum": "md5:checksum"}} + 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_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", + "validate_certs": "yes", + "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): + self.config(image_store_insecure=True, group='ansible') + i_info = self.node.instance_info + i_info['image_checksum'] = 'sha256:checksum' + self.node.instance_info = i_info + self.node.save() + expected = {"image": {"url": "http://image", + "validate_certs": "no", + "source": "fake-image", + "mem_req": 2000, + "disk_format": "qcow2", + "checksum": "sha256:checksum"}} + 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_configdrive_url(self, mem_req_mock): + i_info = self.node.instance_info + i_info['configdrive'] = 'http://configdrive_url' + self.node.instance_info = i_info + self.node.save() + expected = {"image": {"url": "http://image", + "validate_certs": "yes", + "source": "fake-image", + "mem_req": 2000, + "disk_format": "qcow2", + "checksum": "md5:checksum"}, + 'configdrive': {'type': 'url', + 'location': 'http://configdrive_url'}} + 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_configdrive_file(self, mem_req_mock): + i_info = self.node.instance_info + i_info['configdrive'] = 'fake-content' + self.node.instance_info = i_info + self.node.save() + self.config(tempdir='/path/to/tmpfiles') + expected = {"image": {"url": "http://image", + "validate_certs": "yes", + "source": "fake-image", + "mem_req": 2000, + "disk_format": "qcow2", + "checksum": "md5:checksum"}, + 'configdrive': {'type': 'file', + 'location': '/path/to/tmpfiles/%s.cndrive' + % self.node.uuid}} + with mock.patch.object(ansible_deploy, 'open', mock.mock_open(), + create=True) as open_mock: + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual(expected, + ansible_deploy._prepare_variables(task)) + open_mock.assert_has_calls(( + mock.call('/path/to/tmpfiles/%s.cndrive' % self.node.uuid, + 'w'), + mock.call().__enter__(), + mock.call().write('fake-content'), + mock.call().__exit__(None, None, None))) + + def test__validate_clean_steps(self): + steps = [{"interface": "deploy", + "name": "foo", + "args": {"spam": {"required": True, "value": "ham"}}}, + {"name": "bar", + "interface": "deploy"}] + self.assertIsNone(ansible_deploy._validate_clean_steps( + steps, self.node.uuid)) + + def test__validate_clean_steps_missing(self): + steps = [{"name": "foo", + "interface": "deploy", + "args": {"spam": {"value": "ham"}, + "ham": {"required": True}}}, + {"name": "bar"}, + {"interface": "deploy"}] + exc = self.assertRaises(exception.NodeCleaningFailure, + ansible_deploy._validate_clean_steps, + steps, self.node.uuid) + self.assertIn("name foo, field ham.value", six.text_type(exc)) + self.assertIn("name bar, field interface", six.text_type(exc)) + self.assertIn("name undefined, field name", six.text_type(exc)) + + def test__validate_clean_steps_names_not_unique(self): + steps = [{"name": "foo", + "interface": "deploy"}, + {"name": "foo", + "interface": "deploy"}] + exc = self.assertRaises(exception.NodeCleaningFailure, + ansible_deploy._validate_clean_steps, + steps, self.node.uuid) + self.assertIn("unique names", six.text_type(exc)) + + @mock.patch.object(ansible_deploy.yaml, 'safe_load', autospec=True) + def test__get_clean_steps(self, load_mock): + steps = [{"interface": "deploy", + "name": "foo", + "args": {"spam": {"required": True, "value": "ham"}}}, + {"name": "bar", + "interface": "deploy", + "priority": 100}] + load_mock.return_value = steps + expected = [{"interface": "deploy", + "step": "foo", + "priority": 10, + "abortable": False, + "argsinfo": {"spam": {"required": True}}, + "args": {"spam": "ham"}}, + {"interface": "deploy", + "step": "bar", + "priority": 100, + "abortable": False, + "argsinfo": {}, + "args": {}}] + d_info = self.node.driver_info + d_info['ansible_clean_steps_config'] = 'custom_clean' + self.node.driver_info = d_info + self.node.save() + self.config(group='ansible', playbooks_path='/path/to/playbooks') + + with mock.patch.object(ansible_deploy, 'open', mock.mock_open(), + create=True) as open_mock: + self.assertEqual( + expected, + ansible_deploy._get_clean_steps( + self.node, interface="deploy", + override_priorities={"foo": 10})) + open_mock.assert_has_calls(( + mock.call('/path/to/playbooks/custom_clean'),)) + load_mock.assert_called_once_with( + open_mock().__enter__.return_value) + + +class TestAnsibleDeploy(AnsibleDeployTestCaseBase): + def setUp(self): + super(TestAnsibleDeploy, self).setUp() + self.driver = ansible_deploy.AnsibleDeploy() + + def test_get_properties(self): + self.assertEqual( + set(list(ansible_deploy.COMMON_PROPERTIES) + + ['deploy_forces_oob_reboot']), + set(self.driver.get_properties())) + + @mock.patch.object(deploy_utils, 'check_for_missing_params', + autospec=True) + @mock.patch.object(pxe.PXEBoot, 'validate', autospec=True) + def test_validate(self, pxe_boot_validate_mock, check_params_mock): + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + self.driver.validate(task) + pxe_boot_validate_mock.assert_called_once_with( + task.driver.boot, task) + check_params_mock.assert_called_once_with( + {'instance_info.image_source': INSTANCE_INFO['image_source']}, + mock.ANY) + + @mock.patch.object(deploy_utils, 'get_boot_option', + return_value='netboot', autospec=True) + @mock.patch.object(pxe.PXEBoot, 'validate', autospec=True) + def test_validate_not_iwdi_netboot(self, pxe_boot_validate_mock, + get_boot_mock): + driver_internal_info = dict(DRIVER_INTERNAL_INFO) + driver_internal_info['is_whole_disk_image'] = False + self.node.driver_internal_info = driver_internal_info + self.node.save() + + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + self.assertRaises(exception.InvalidParameterValue, + self.driver.validate, task) + pxe_boot_validate_mock.assert_called_once_with( + task.driver.boot, task) + get_boot_mock.assert_called_once_with(task.node) + + @mock.patch.object(utils, 'node_power_action', autospec=True) + def test_deploy(self, power_mock): + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + driver_return = self.driver.deploy(task) + self.assertEqual(driver_return, states.DEPLOYWAIT) + power_mock.assert_called_once_with(task, states.REBOOT) + + @mock.patch.object(utils, 'node_power_action', autospec=True) + def test_tear_down(self, power_mock): + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + driver_return = self.driver.tear_down(task) + power_mock.assert_called_once_with(task, states.POWER_OFF) + self.assertEqual(driver_return, states.DELETED) + + @mock.patch('ironic.conductor.utils.node_power_action', autospec=True) + @mock.patch('ironic.drivers.modules.deploy_utils.build_agent_options', + return_value={'op1': 'test1'}, autospec=True) + @mock.patch('ironic.drivers.modules.deploy_utils.' + 'build_instance_info_for_deploy', + return_value={'test': 'test'}, autospec=True) + @mock.patch.object(pxe.PXEBoot, 'prepare_ramdisk') + def test_prepare(self, pxe_prepare_ramdisk_mock, + build_instance_info_mock, build_options_mock, + power_action_mock): + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + task.node.provision_state = states.DEPLOYING + + with mock.patch.object(task.driver.network, + 'add_provisioning_network', + autospec=True) as net_mock: + self.driver.prepare(task) + + net_mock.assert_called_once_with(task) + power_action_mock.assert_called_once_with(task, + states.POWER_OFF) + build_instance_info_mock.assert_called_once_with(task) + build_options_mock.assert_called_once_with(task.node) + pxe_prepare_ramdisk_mock.assert_called_once_with( + task, {'op1': 'test1'}) + + self.node.refresh() + self.assertEqual('test', self.node.instance_info['test']) + + @mock.patch.object(ansible_deploy, '_get_configdrive_path', + return_value='/path/test', autospec=True) + @mock.patch.object(irlib_utils, 'unlink_without_raise', autospec=True) + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk') + def test_clean_up(self, pxe_clean_up_mock, unlink_mock, + get_cfdrive_path_mock): + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + self.driver.clean_up(task) + pxe_clean_up_mock.assert_called_once_with(task) + get_cfdrive_path_mock.assert_called_once_with(self.node['uuid']) + unlink_mock.assert_called_once_with('/path/test') + + @mock.patch.object(ansible_deploy, '_get_clean_steps', autospec=True) + def test_get_clean_steps(self, get_clean_steps_mock): + mock_steps = [{'priority': 10, 'interface': 'deploy', + 'step': 'erase_devices'}, + {'priority': 99, 'interface': 'deploy', + 'step': 'erase_devices_metadata'}, + ] + get_clean_steps_mock.return_value = mock_steps + with task_manager.acquire(self.context, self.node.uuid) as task: + steps = self.driver.get_clean_steps(task) + get_clean_steps_mock.assert_called_once_with( + task.node, interface='deploy', + override_priorities={ + 'erase_devices': None, + 'erase_devices_metadata': None}) + self.assertEqual(mock_steps, steps) + + @mock.patch.object(ansible_deploy, '_get_clean_steps', autospec=True) + def test_get_clean_steps_priority(self, mock_get_clean_steps): + self.config(erase_devices_priority=9, group='deploy') + self.config(erase_devices_metadata_priority=98, group='deploy') + mock_steps = [{'priority': 9, 'interface': 'deploy', + 'step': 'erase_devices'}, + {'priority': 98, 'interface': 'deploy', + 'step': 'erase_devices_metadata'}, + ] + mock_get_clean_steps.return_value = mock_steps + + with task_manager.acquire(self.context, self.node.uuid) as task: + steps = self.driver.get_clean_steps(task) + mock_get_clean_steps.assert_called_once_with( + task.node, interface='deploy', + override_priorities={'erase_devices': 9, + 'erase_devices_metadata': 98}) + self.assertEqual(mock_steps, steps) + + @mock.patch.object(ansible_deploy, '_run_playbook', autospec=True) + @mock.patch.object(ansible_deploy, '_prepare_extra_vars', autospec=True) + @mock.patch.object(ansible_deploy, '_parse_ansible_driver_info', + return_value=('test_pl', 'test_u', 'test_k'), + autospec=True) + def test_execute_clean_step(self, parse_driver_info_mock, + prepare_extra_mock, run_playbook_mock): + + step = {'priority': 10, 'interface': 'deploy', + 'step': 'erase_devices', 'args': {'tags': ['clean']}} + ironic_nodes = { + 'ironic_nodes': [(self.node['uuid'], '127.0.0.1', 'test_u', {})]} + prepare_extra_mock.return_value = ironic_nodes + di_info = self.node.driver_internal_info + di_info['agent_url'] = 'http://127.0.0.1' + self.node.driver_internal_info = di_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.execute_clean_step(task, step) + + parse_driver_info_mock.assert_called_once_with( + task.node, action='clean') + prepare_extra_mock.assert_called_once_with( + ironic_nodes['ironic_nodes']) + run_playbook_mock.assert_called_once_with( + 'test_pl', ironic_nodes, 'test_k', tags=['clean']) + + @mock.patch.object(ansible_deploy, '_parse_ansible_driver_info', + return_value=('test_pl', 'test_u', 'test_k'), + autospec=True) + @mock.patch.object(utils, 'cleaning_error_handler', autospec=True) + @mock.patch.object(ansible_deploy, '_run_playbook', autospec=True) + @mock.patch.object(ansible_deploy, 'LOG', autospec=True) + def test_execute_clean_step_no_success_log( + self, log_mock, run_mock, utils_mock, parse_driver_info_mock): + + run_mock.side_effect = exception.InstanceDeployFailure('Boom') + step = {'priority': 10, 'interface': 'deploy', + 'step': 'erase_devices', 'args': {'tags': ['clean']}} + di_info = self.node.driver_internal_info + di_info['agent_url'] = 'http://127.0.0.1' + self.node.driver_internal_info = di_info + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.execute_clean_step(task, step) + log_mock.error.assert_called_once_with( + mock.ANY, {'node': task.node['uuid'], + 'step': 'erase_devices'}) + utils_mock.assert_called_once_with(task, 'Boom') + self.assertFalse(log_mock.info.called) + + @mock.patch.object(ansible_deploy, '_run_playbook', autospec=True) + @mock.patch.object(utils, 'set_node_cleaning_steps', autospec=True) + @mock.patch.object(utils, 'node_power_action', autospec=True) + @mock.patch('ironic.drivers.modules.deploy_utils.build_agent_options', + return_value={'op1': 'test1'}, autospec=True) + @mock.patch.object(pxe.PXEBoot, 'prepare_ramdisk') + def test_prepare_cleaning( + self, prepare_ramdisk_mock, buid_options_mock, power_action_mock, + set_node_cleaning_steps, run_playbook_mock): + step = {'priority': 10, 'interface': 'deploy', + 'step': 'erase_devices', 'tags': ['clean']} + driver_internal_info = dict(DRIVER_INTERNAL_INFO) + driver_internal_info['clean_steps'] = [step] + self.node.driver_internal_info = driver_internal_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.network.add_cleaning_network = mock.Mock() + + state = self.driver.prepare_cleaning(task) + + set_node_cleaning_steps.assert_called_once_with(task) + task.driver.network.add_cleaning_network.assert_called_once_with( + task) + buid_options_mock.assert_called_once_with(task.node) + prepare_ramdisk_mock.assert_called_once_with( + task, {'op1': 'test1'}) + power_action_mock.assert_called_once_with(task, states.REBOOT) + self.assertFalse(run_playbook_mock.called) + self.assertEqual(states.CLEANWAIT, state) + + @mock.patch.object(utils, 'set_node_cleaning_steps', autospec=True) + def test_prepare_cleaning_callback_no_steps(self, + set_node_cleaning_steps): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.network.add_cleaning_network = mock.Mock() + + self.driver.prepare_cleaning(task) + + set_node_cleaning_steps.assert_called_once_with(task) + self.assertFalse(task.driver.network.add_cleaning_network.called) + + @mock.patch.object(utils, 'node_power_action', autospec=True) + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk') + def test_tear_down_cleaning(self, clean_ramdisk_mock, power_action_mock): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.network.remove_cleaning_network = mock.Mock() + + self.driver.tear_down_cleaning(task) + + power_action_mock.assert_called_once_with(task, states.POWER_OFF) + clean_ramdisk_mock.assert_called_once_with(task) + (task.driver.network.remove_cleaning_network + .assert_called_once_with(task)) + + @mock.patch.object(ansible_deploy, '_run_playbook', autospec=True) + @mock.patch.object(ansible_deploy, '_prepare_extra_vars', autospec=True) + @mock.patch.object(ansible_deploy, '_parse_ansible_driver_info', + return_value=('test_pl', 'test_u', 'test_k'), + autospec=True) + @mock.patch.object(ansible_deploy, '_parse_partitioning_info', + autospec=True) + @mock.patch.object(ansible_deploy, '_prepare_variables', autospec=True) + def test__ansible_deploy(self, prepare_vars_mock, parse_part_info_mock, + parse_dr_info_mock, prepare_extra_mock, + run_playbook_mock): + ironic_nodes = { + 'ironic_nodes': [(self.node['uuid'], '127.0.0.1', 'test_u')]} + prepare_extra_mock.return_value = ironic_nodes + _vars = { + 'url': 'image_url', + 'checksum': 'aa'} + prepare_vars_mock.return_value = _vars + + driver_internal_info = dict(DRIVER_INTERNAL_INFO) + driver_internal_info['is_whole_disk_image'] = False + self.node.driver_internal_info = driver_internal_info + self.node.extra = {'ham': 'spam'} + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver._ansible_deploy(task, '127.0.0.1') + + prepare_vars_mock.assert_called_once_with(task) + parse_part_info_mock.assert_called_once_with(task.node) + parse_dr_info_mock.assert_called_once_with(task.node) + prepare_extra_mock.assert_called_once_with( + [(self.node['uuid'], '127.0.0.1', 'test_u', {'ham': 'spam'})], + variables=_vars) + run_playbook_mock.assert_called_once_with( + 'test_pl', ironic_nodes, 'test_k') + + @mock.patch.object(ansible_deploy, '_run_playbook', autospec=True) + @mock.patch.object(ansible_deploy, '_prepare_extra_vars', autospec=True) + @mock.patch.object(ansible_deploy, '_parse_ansible_driver_info', + return_value=('test_pl', 'test_u', 'test_k'), + autospec=True) + @mock.patch.object(ansible_deploy, '_parse_partitioning_info', + autospec=True) + @mock.patch.object(ansible_deploy, '_prepare_variables', autospec=True) + def test__ansible_deploy_iwdi(self, prepare_vars_mock, + parse_part_info_mock, parse_dr_info_mock, + prepare_extra_mock, run_playbook_mock): + ironic_nodes = { + 'ironic_nodes': [(self.node['uuid'], '127.0.0.1', 'test_u')]} + prepare_extra_mock.return_value = ironic_nodes + _vars = { + 'url': 'image_url', + 'checksum': 'aa'} + prepare_vars_mock.return_value = _vars + driver_internal_info = self.node.driver_internal_info + driver_internal_info['is_whole_disk_image'] = True + self.node.driver_internal_info = driver_internal_info + self.node.extra = {'ham': 'spam'} + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver._ansible_deploy(task, '127.0.0.1') + + prepare_vars_mock.assert_called_once_with(task) + self.assertFalse(parse_part_info_mock.called) + parse_dr_info_mock.assert_called_once_with(task.node) + prepare_extra_mock.assert_called_once_with( + [(self.node['uuid'], '127.0.0.1', 'test_u', {'ham': 'spam'})], + variables=_vars) + run_playbook_mock.assert_called_once_with('test_pl', ironic_nodes, + 'test_k') + + @mock.patch.object(fake.FakePower, 'get_power_state', + return_value=states.POWER_OFF) + @mock.patch.object(utils, 'node_power_action', autospec=True) + def test_reboot_and_finish_deploy_force_reboot(self, power_action_mock, + get_pow_state_mock): + d_info = self.node.driver_info + d_info['deploy_forces_oob_reboot'] = True + self.node.driver_info = d_info + self.node.save() + self.config(group='ansible', + post_deploy_get_power_state_retry_interval=0) + self.node.provision_state = states.DEPLOYING + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + with mock.patch.object(task.driver, 'network') as net_mock: + self.driver.reboot_and_finish_deploy(task) + net_mock.remove_provisioning_network.assert_called_once_with( + task) + net_mock.configure_tenant_networks.assert_called_once_with( + task) + expected_power_calls = [((task, states.POWER_OFF),), + ((task, states.POWER_ON),)] + self.assertEqual(expected_power_calls, + power_action_mock.call_args_list) + get_pow_state_mock.assert_not_called() + + @mock.patch.object(ansible_deploy, '_run_playbook', autospec=True) + @mock.patch.object(utils, 'node_power_action', autospec=True) + def test_reboot_and_finish_deploy_soft_poweroff_retry(self, + power_action_mock, + ansible_mock): + self.config(group='ansible', + post_deploy_get_power_state_retry_interval=0) + self.config(group='ansible', + post_deploy_get_power_state_retries=1) + self.node.provision_state = states.DEPLOYING + di_info = self.node.driver_internal_info + di_info['agent_url'] = 'http://127.0.0.1' + self.node.driver_internal_info = di_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + with mock.patch.object(task.driver, 'network') as net_mock: + with mock.patch.object(task.driver.power, + 'get_power_state', + return_value=states.POWER_ON) as p_mock: + self.driver.reboot_and_finish_deploy(task) + p_mock.assert_called_with(task) + self.assertEqual(2, len(p_mock.mock_calls)) + net_mock.remove_provisioning_network.assert_called_once_with( + task) + net_mock.configure_tenant_networks.assert_called_once_with( + task) + power_action_mock.assert_has_calls( + [mock.call(task, states.POWER_OFF), + mock.call(task, states.POWER_ON)]) + expected_power_calls = [((task, states.POWER_OFF),), + ((task, states.POWER_ON),)] + self.assertEqual(expected_power_calls, + power_action_mock.call_args_list) + ansible_mock.assert_called_once_with('shutdown.yaml', + mock.ANY, mock.ANY) + + @mock.patch.object(ansible_deploy, '_get_node_ip', autospec=True, + return_value='1.2.3.4') + def test_continue_deploy(self, getip_mock): + self.node.provision_state = states.DEPLOYWAIT + self.node.target_provision_state = states.ACTIVE + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + with mock.patch.multiple(self.driver, autospec=True, + _ansible_deploy=mock.DEFAULT, + reboot_to_instance=mock.DEFAULT): + self.driver.continue_deploy(task) + getip_mock.assert_called_once_with(task) + self.driver._ansible_deploy.assert_called_once_with( + task, '1.2.3.4') + self.driver.reboot_to_instance.assert_called_once_with(task) + self.assertEqual(states.ACTIVE, task.node.target_provision_state) + self.assertEqual(states.DEPLOYING, task.node.provision_state) + + @mock.patch.object(utils, 'node_set_boot_device', autospec=True) + def test_reboot_to_instance(self, bootdev_mock): + with task_manager.acquire(self.context, self.node.uuid) as task: + with mock.patch.object(self.driver, 'reboot_and_finish_deploy', + autospec=True): + task.driver.boot = mock.Mock() + self.driver.reboot_to_instance(task) + bootdev_mock.assert_called_once_with(task, 'disk', + persistent=True) + self.driver.reboot_and_finish_deploy.assert_called_once_with( + task) + task.driver.boot.clean_up_ramdisk.assert_called_once_with( + task) diff --git a/releasenotes/notes/ansible-deploy-15da234580ca0c30.yaml b/releasenotes/notes/ansible-deploy-15da234580ca0c30.yaml new file mode 100644 index 0000000000..c49b3e700b --- /dev/null +++ b/releasenotes/notes/ansible-deploy-15da234580ca0c30.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Adds a new ``ansible`` deploy interface. It targets mostly undercloud + use-case by allowing greater customization of provisioning process. + + This new deploy interface is usable only with hardware types. + It is set as supported for a ``generic`` hardware type and all + its subclasses, but must be explicitly enabled in the + ``[DEFAULT]enabled_deploy_interfaces`` configuration file option + to actually allow setting nodes to use it. diff --git a/setup.cfg b/setup.cfg index 6c32425356..9ed647b558 100644 --- a/setup.cfg +++ b/setup.cfg @@ -99,6 +99,7 @@ ironic.hardware.interfaces.console = no-console = ironic.drivers.modules.noop:NoConsole ironic.hardware.interfaces.deploy = + ansible = ironic.drivers.modules.ansible.deploy:AnsibleDeploy direct = ironic.drivers.modules.agent:AgentDeploy fake = ironic.drivers.modules.fake:FakeDeploy iscsi = ironic.drivers.modules.iscsi_deploy:ISCSIDeploy @@ -187,6 +188,7 @@ autodoc_exclude_modules = ironic.db.sqlalchemy.alembic.env ironic.db.sqlalchemy.alembic.versions.* ironic_tempest_plugin.* + ironic.drivers.modules.ansible.playbooks* api_doc_dir = contributor/api [build_sphinx]