From ffd006e098abf32c2d830194fa7fbb778b3b2742 Mon Sep 17 00:00:00 2001 From: Pavlo Shchelokovskyy Date: Fri, 8 Jul 2016 20:49:39 +0300 Subject: [PATCH] Add Ansible-deploy driver Requires Ironic API >= 1.22 when using heartbeats to Ironic API. For better logging and proper deployment failure handling, Ironic should be > '6.1.1.dev147' version. Tested with and targets Ansible >= 2.1 Experimental DIB element: I3f6c3baf0197d27f2d423f52611666ca186cd0a4 Experimental TinyCore-based bootstrap: Ie39ce67dc93e7d53bf75937c7defacafad5fbfcf Tested with DevStack, Bifrost and Mirantis OpenStack, with both VMs and real IPMI hardware. Tempest's baremetal_server_basic_ops test reliably passes on DevStack with `pxe_ssh_ansible` driver. More elaborate documentation will be proposed in next changes. Change-Id: Ib9317d365d7bc39aa00a9d9e1eadddd2f9b2947f Related-bug: #1526308 Co-Authored-By: Yuriy Zveryanskyy --- devstack/enabled-drivers.txt | 3 + ironic_staging_drivers/ansible/__init__.py | 67 ++ ironic_staging_drivers/ansible/deploy.py | 746 +++++++++++++++++ .../ansible/playbooks/add-ironic-nodes.yaml | 11 + .../ansible/playbooks/ansible.cfg | 24 + .../playbooks/callback_plugins/ironic_log.ini | 8 + .../playbooks/callback_plugins/ironic_log.py | 122 +++ .../ansible/playbooks/clean.yaml | 12 + .../ansible/playbooks/clean_steps.yaml | 19 + .../ansible/playbooks/deploy.yaml | 13 + .../ansible/playbooks/inventory | 1 + .../ansible/playbooks/library/parted.py | 111 +++ .../ansible/playbooks/library/stream_url.py | 104 +++ .../playbooks/roles/clean/tasks/main.yaml | 6 + .../playbooks/roles/clean/tasks/shred.yaml | 6 + .../playbooks/roles/clean/tasks/zap.yaml | 4 + .../roles/deploy/files/install_grub.sh | 54 ++ .../deploy/files/partition_configdrive.sh | 115 +++ .../roles/deploy/tasks/configdrive.yaml | 37 + .../roles/deploy/tasks/download.yaml | 11 + .../playbooks/roles/deploy/tasks/grub.yaml | 3 + .../playbooks/roles/deploy/tasks/main.yaml | 17 + .../playbooks/roles/deploy/tasks/parted.yaml | 28 + .../roles/deploy/tasks/root-device.yaml | 7 + .../playbooks/roles/deploy/tasks/write.yaml | 19 + .../playbooks/roles/shutdown/tasks/main.yaml | 6 + .../playbooks/roles/wait/tasks/main.yaml | 10 + .../ansible/python-requirements.txt | 1 + .../tests/unit/ansible/__init__.py | 0 .../tests/unit/ansible/test_deploy.py | 778 ++++++++++++++++++ .../ansible-deploy-63d94ae3857bf7d0.yaml | 22 + setup.cfg | 4 + 32 files changed, 2369 insertions(+) create mode 100644 ironic_staging_drivers/ansible/__init__.py create mode 100644 ironic_staging_drivers/ansible/deploy.py create mode 100644 ironic_staging_drivers/ansible/playbooks/add-ironic-nodes.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/ansible.cfg create mode 100644 ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.ini create mode 100644 ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.py create mode 100644 ironic_staging_drivers/ansible/playbooks/clean.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/clean_steps.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/deploy.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/inventory create mode 100644 ironic_staging_drivers/ansible/playbooks/library/parted.py create mode 100644 ironic_staging_drivers/ansible/playbooks/library/stream_url.py create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/main.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/shred.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/zap.yaml create mode 100755 ironic_staging_drivers/ansible/playbooks/roles/deploy/files/install_grub.sh create mode 100755 ironic_staging_drivers/ansible/playbooks/roles/deploy/files/partition_configdrive.sh create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/configdrive.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/download.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/grub.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/main.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/parted.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/root-device.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/write.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/shutdown/tasks/main.yaml create mode 100644 ironic_staging_drivers/ansible/playbooks/roles/wait/tasks/main.yaml create mode 100644 ironic_staging_drivers/ansible/python-requirements.txt create mode 100644 ironic_staging_drivers/tests/unit/ansible/__init__.py create mode 100644 ironic_staging_drivers/tests/unit/ansible/test_deploy.py create mode 100644 releasenotes/notes/ansible-deploy-63d94ae3857bf7d0.yaml diff --git a/devstack/enabled-drivers.txt b/devstack/enabled-drivers.txt index f83807e..d2700c2 100644 --- a/devstack/enabled-drivers.txt +++ b/devstack/enabled-drivers.txt @@ -9,3 +9,6 @@ fake_libvirt_fake fake_amt_fake pxe_amt_iscsi pxe_amt_agent +pxe_ssh_ansible +pxe_libvirt_ansible +pxe_ipmitool_ansible diff --git a/ironic_staging_drivers/ansible/__init__.py b/ironic_staging_drivers/ansible/__init__.py new file mode 100644 index 0000000..fc2f722 --- /dev/null +++ b/ironic_staging_drivers/ansible/__init__.py @@ -0,0 +1,67 @@ +# 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.drivers import base +from ironic.drivers.modules import fake +from ironic.drivers.modules import ipmitool +from ironic.drivers.modules import pxe +from ironic.drivers.modules import ssh + +from ironic_staging_drivers.ansible import deploy as ansible_deploy +from ironic_staging_drivers.libvirt import power as libvirt_power + + +class AnsibleAndSSHDriver(base.BaseDriver): + """Ansible + SSH driver. + + NOTE: This driver is meant only for testing environments. + """ + + def __init__(self): + self.power = ssh.SSHPower() + self.boot = pxe.PXEBoot() + self.deploy = ansible_deploy.AnsibleDeploy() + self.management = ssh.SSHManagement() + + +class AnsibleAndIPMIToolDriver(base.BaseDriver): + """Ansible + Ipmitool driver.""" + + def __init__(self): + self.power = ipmitool.IPMIPower() + self.boot = pxe.PXEBoot() + self.deploy = ansible_deploy.AnsibleDeploy() + self.management = ipmitool.IPMIManagement() + self.vendor = ipmitool.VendorPassthru() + + +class FakeAnsibleDriver(base.BaseDriver): + """Ansible + Fake driver""" + + def __init__(self): + self.power = fake.FakePower() + self.boot = pxe.PXEBoot() + self.deploy = ansible_deploy.AnsibleDeploy() + self.management = fake.FakeManagement() + + +class AnsibleAndLibvirtDriver(base.BaseDriver): + """Ansible + Libvirt driver. + + NOTE: This driver is meant only for testing environments. + """ + + def __init__(self): + self.power = libvirt_power.LibvirtPower() + self.boot = pxe.PXEBoot() + self.deploy = ansible_deploy.AnsibleDeploy() + self.management = libvirt_power.LibvirtManagement() diff --git a/ironic_staging_drivers/ansible/deploy.py b/ironic_staging_drivers/ansible/deploy.py new file mode 100644 index 0000000..2757404 --- /dev/null +++ b/ironic_staging_drivers/ansible/deploy.py @@ -0,0 +1,746 @@ +# +# 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 driver +""" + +import json +import os +import shlex + +from ironic_lib import utils as irlib_utils +from oslo_concurrency import processutils +from oslo_config import cfg +from oslo_log import log +from oslo_utils import excutils +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.glance_service import service_utils +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common.i18n import _LI +from ironic.common.i18n import _LW +from ironic.common import image_service +from ironic.common import images +from ironic.common import states +from ironic.common import utils +from ironic.conductor import rpcapi +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 deploy_utils + + +ansible_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(os.path.dirname(__file__), 'playbooks'), + help=_('Path to directory with playbooks, roles and ' + 'local inventory.')), + cfg.StrOpt('config_file_path', + default=os.path.join( + os.path.dirname(__file__), '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.')), + 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('use_ramdisk_callback', + default=True, + help=_('Use callback request from ramdisk for start deploy or ' + 'cleaning. Disable it when using custom ramdisk ' + 'without callback script. ' + 'When callback is disabled, Neutron is mandatory.')), +] + +CONF.register_opts(ansible_opts, group='ansible') + +LOG = log.getLogger(__name__) + + +DEFAULT_PLAYBOOKS = { + 'deploy': 'deploy.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_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 + +DISK_LAYOUT_PARAMS = ('root_gb', 'swap_mb', 'ephemeral_gb') + +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') + + +# NOTE(yuriyz): this is a copy from agent driver +def build_instance_info_for_deploy(task): + """Build instance_info necessary for deploying to a node.""" + node = task.node + instance_info = node.instance_info + + image_source = instance_info['image_source'] + if service_utils.is_glance_image(image_source): + glance = image_service.GlanceImageService(version=2, + context=task.context) + image_info = glance.show(image_source) + swift_temp_url = glance.swift_temp_url(image_info) + LOG.debug('Got image info: %(info)s for node %(node)s.', + {'info': image_info, 'node': node.uuid}) + instance_info['image_url'] = swift_temp_url + instance_info['image_checksum'] = image_info['checksum'] + instance_info['image_disk_format'] = image_info['disk_format'] + else: + try: + image_service.HttpImageService().validate_href(image_source) + except exception.ImageRefValidationFailed: + with excutils.save_and_reraise_exception(): + LOG.error(_LE("Ansible deploy supports only HTTP(S) URLs as " + "instance_info['image_source']. Either %s " + "is not a valid HTTP(S) URL or " + "is not reachable."), image_source) + instance_info['image_url'] = image_source + + return instance_info + + +def _get_node_ip(task): + api = dhcp_factory.DHCPFactory().provider + ip_addrs = api.get_ip_addresses(task) + if not ip_addrs: + raise exception.FailedToGetIPAddressOnPort(_( + "Failed to get IP address for any port on node %s.") % + task.node.uuid) + if len(ip_addrs) > 1: + error = _("Ansible driver does not support multiple IP addresses " + "during deploy or cleaning") + raise exception.InstanceDeployFailure(reason=error) + + return ip_addrs[0] + + +# some good code from agent +def _reboot_and_finish_deploy(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) + + try: + _wait_until_powered_off(task) + except Exception as e: + LOG.warning(_LW('Failed to soft power off node %(node_uuid)s ' + 'in at least %(timeout)d seconds. Error: %(error)s'), + {'node_uuid': task.node.uuid, + 'timeout': (wait * (attempts - 1)) / 1000, + 'error': e}) + 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) + + +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(ironic_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) + args = [CONF.ansible.ansible_playbook_script, playbook, + '-i', INVENTORY_FILE, + '-e', json.dumps(extra_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 = {} + + i_info['root_gb'] = info.get('root_gb') + error_msg = _("'root_gb' is missing in node's instance_info") + deploy_utils.check_for_missing_params(i_info, error_msg) + + i_info['swap_mb'] = info.get('swap_mb', 0) + i_info['ephemeral_gb'] = info.get('ephemeral_gb', 0) + err_msg_invalid = _("Cannot validate parameter for deploy. Invalid " + "parameter %(param)s. Reason: %(reason)s") + + for param in DISK_LAYOUT_PARAMS: + try: + i_info[param] = int(i_info[param]) + except ValueError: + reason = _("%s is not an integer value") % i_info[param] + raise exception.InvalidParameterValue(err_msg_invalid % + {'param': param, + 'reason': reason}) + # convert to sizes expected by 'parted' Ansible module + root_mib = 1024 * i_info.pop('root_gb') + swap_mib = i_info.pop('swap_mb') + ephemeral_mib = 1024 * i_info.pop('ephemeral_gb') + + partitions = [] + root_partition = {'name': 'root', + 'size_mib': root_mib, + 'boot': 'yes', + 'swap': 'no'} + partitions.append(root_partition) + + if swap_mib: + swap_partition = {'name': 'swap', + 'size_mib': swap_mib, + 'boot': 'no', + 'swap': 'yes'} + partitions.append(swap_partition) + + if ephemeral_mib: + ephemeral_partition = {'name': 'ephemeral', + 'size_mib': ephemeral_mib, + 'boot': 'no', + 'swap': 'no'} + partitions.append(ephemeral_partition) + i_info['ephemeral_format'] = info.get('ephemeral_format') + if not i_info['ephemeral_format']: + i_info['ephemeral_format'] = CONF.pxe.default_ephemeral_format + preserve_ephemeral = info.get('preserve_ephemeral', False) + try: + i_info['preserve_ephemeral'] = ( + strutils.bool_from_string(preserve_ephemeral, strict=True)) + except ValueError as e: + raise exception.InvalidParameterValue( + err_msg_invalid % {'param': 'preserve_ephemeral', 'reason': e}) + i_info['preserve_ephemeral'] = ( + 'yes' if i_info['preserve_ephemeral'] else 'no') + + i_info['ironic_partitions'] = partitions + return i_info + + +def _prepare_variables(task): + node = task.node + i_info = node.instance_info + image = { + 'url': i_info['image_url'], + 'mem_req': _calculate_memory_req(task), + 'disk_format': i_info.get('image_disk_format'), + } + checksum = i_info.get('image_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: + checksum = 'md5:%s' % checksum + image['checksum'] = checksum + 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} + return variables + + +def _validate_clean_steps(steps, node_uuid): + missing = [] + for step in steps: + name = step.setdefault('name', 'unnamed') + 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) + + +def _get_clean_steps(task, interface=None, override_priorities=None): + """Get cleaning steps.""" + clean_steps_file = task.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=task.node.uuid, reason=msg) + + _validate_clean_steps(internal_steps, task.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 + + +# taken from agent driver +def _notify_conductor_resume_clean(task): + LOG.debug('Sending RPC to conductor to resume cleaning for node %s', + task.node.uuid) + uuid = task.node.uuid + rpc = rpcapi.ConductorAPI() + topic = rpc.get_topic_for(task.node) + # Need to release the lock to let the conductor take it + task.release_resources() + rpc.continue_node_clean(task.context, uuid, topic=topic) + + +def _deploy(task, node_address): + """Internal function for deployment to a node.""" + notags = ['wait'] if CONF.ansible.use_ramdisk_callback else [] + node = task.node + LOG.debug('IP of node %(node)s is %(ip)s', + {'node': node.uuid, 'ip': node_address}) + iwdi = node.driver_internal_info.get('is_whole_disk_image') + variables = _prepare_variables(task) + if iwdi: + notags.append('parted') + else: + 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, notags=notags) + LOG.info(_LI('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) + _reboot_and_finish_deploy(task) + + task.driver.boot.clean_up_ramdisk(task) + + +class AnsibleDeploy(base.DeployInterface): + """Interface for deploy-related actions.""" + + def get_properties(self): + """Return the properties of the interface.""" + return COMMON_PROPERTIES + + 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) + + @task_manager.require_exclusive_lock + def deploy(self, task): + """Perform a deployment to a node.""" + manager_utils.node_power_action(task, states.REBOOT) + if CONF.ansible.use_ramdisk_callback: + return states.DEPLOYWAIT + node = task.node + ip_addr = _get_node_ip(task) + try: + _deploy(task, ip_addr) + except Exception as e: + error = _('Deploy failed for node %(node)s: ' + 'Error: %(exc)s') % {'node': node.uuid, + 'exc': six.text_type(e)} + LOG.exception(error) + self._set_failed_state(task, error) + + else: + LOG.info(_LI('Deployment to node %s done'), node.uuid) + return states.DEPLOYDONE + + @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 + + 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 = build_instance_info_for_deploy(task) + node.save() + boot_opt = deploy_utils.build_agent_options(node) + task.driver.boot.prepare_ramdisk(task, boot_opt) + + 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(_LE("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, interface='deploy', + override_priorities=new_priorities) + + 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'] + try: + ip_addr = node.driver_internal_info['ansible_cleaning_ip'] + except KeyError: + raise exception.NodeCleaningFailure(node=node.uuid, + reason='undefined node IP ' + 'addresses') + node_list = [(node.uuid, ip_addr, 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(_LE("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)) + LOG.info(_LI('Ansible completed cleaning step %(step)s ' + 'on node %(node)s.'), + {'node': node.uuid, 'step': stepname}) + + 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 + use_callback = CONF.ansible.use_ramdisk_callback + if use_callback: + manager_utils.set_node_cleaning_steps(task) + if not node.driver_internal_info['clean_steps']: + # no clean steps configured, nothing to do. + return + deploy_utils.prepare_cleaning_ports(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) + if use_callback: + return states.CLEANWAIT + + ip_addr = _get_node_ip(task) + LOG.debug('IP of node %(node)s is %(ip)s', + {'node': node.uuid, 'ip': ip_addr}) + driver_internal_info = node.driver_internal_info + driver_internal_info['ansible_cleaning_ip'] = ip_addr + node.driver_internal_info = driver_internal_info + node.save() + playbook, user, key = _parse_ansible_driver_info( + task.node, action='clean') + node_list = [(node.uuid, ip_addr, user, node.extra)] + extra_vars = _prepare_extra_vars(node_list) + + LOG.debug('Waiting ramdisk on node %s for cleaning', node.uuid) + _run_playbook(playbook, extra_vars, key, tags=['wait']) + LOG.info(_LI('Node %s is ready for cleaning'), node.uuid) + + 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 + """ + node = task.node + driver_internal_info = node.driver_internal_info + driver_internal_info.pop('ansible_cleaning_ip', None) + node.driver_internal_info = driver_internal_info + node.save() + manager_utils.node_power_action(task, states.POWER_OFF) + task.driver.boot.clean_up_ramdisk(task) + deploy_utils.tear_down_cleaning_ports(task) + + # FIXME(pas-ha): remove this workaround after nearest Ironic release + # that contains the specified commit (next after 6.1.0) + # and require this Ironic release + def _upgrade_lock(self, task, purpose=None): + try: + task.upgrade_lock(purpose=purpose) + except TypeError: + LOG.warning(_LW("To have better logging please update your " + "Ironic installation to contain commit " + "2a73b50a7fb29c4e73511d2294aa19c37d96c969.")) + task.upgrade_lock() + + # FIXME(pas-ha): remove this workaround after nearest Ironic release + # that contains the specified commit (next after 6.1.0) + # and require this Ironic release + def _set_failed_state(self, task, error): + try: + deploy_utils.set_failed_state(task, error, collect_logs=False) + except TypeError: + LOG.warning(_LW("To have proper error handling please update " + "your Ironic installation to contain commit " + "bb62f256f7aa55c292ebeae73ca25a4a9f0ec8c0.")) + deploy_utils.set_failed_state(task, error) + + def heartbeat(self, task, callback_url): + """Method for ansible ramdisk callback.""" + node = task.node + address = urlparse.urlparse(callback_url).netloc.split(':')[0] + + if node.maintenance: + # this shouldn't happen often, but skip the rest if it does. + LOG.debug('Heartbeat from node %(node)s in maintenance mode; ' + 'not taking any action.', {'node': node.uuid}) + elif node.provision_state == states.DEPLOYWAIT: + LOG.debug('Heartbeat from %(node)s.', {'node': node.uuid}) + self._upgrade_lock(task, purpose='deploy') + node = task.node + task.process_event('resume') + try: + _deploy(task, address) + except Exception as e: + error = _('Deploy failed for node %(node)s: ' + 'Error: %(exc)s') % {'node': node.uuid, + 'exc': six.text_type(e)} + LOG.exception(error) + self._set_failed_state(task, error) + + else: + LOG.info(_LI('Deployment to node %s done'), node.uuid) + task.process_event('done') + + elif node.provision_state == states.CLEANWAIT: + LOG.debug('Node %s just booted to start cleaning.', + node.uuid) + self._upgrade_lock(task, purpose='clean') + node = task.node + driver_internal_info = node.driver_internal_info + driver_internal_info['ansible_cleaning_ip'] = address + node.driver_internal_info = driver_internal_info + node.save() + try: + _notify_conductor_resume_clean(task) + except Exception as e: + error = _('cleaning failed for node %(node)s: ' + 'Error: %(exc)s') % {'node': node.uuid, + 'exc': six.text_type(e)} + LOG.exception(error) + manager_utils.cleaning_error_handler(task, error) + + else: + LOG.warning(_LW('Call back from %(node)s in invalid provision ' + 'state %(state)s'), + {'node': node.uuid, 'state': node.provision_state}) diff --git a/ironic_staging_drivers/ansible/playbooks/add-ironic-nodes.yaml b/ironic_staging_drivers/ansible/playbooks/add-ironic-nodes.yaml new file mode 100644 index 0000000..872bca8 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/add-ironic-nodes.yaml @@ -0,0 +1,11 @@ +- hosts: conductor + gather_facts: no + tasks: + - add_host: + group: ironic + hostname: "{{ item.name }}" + ansible_ssh_host: "{{ item.ip }}" + ansible_ssh_user: "{{ item.user }}" + ironic_extra: "{{ item.extra | default({}) }}" + with_items: "{{ ironic_nodes }}" + tags: always diff --git a/ironic_staging_drivers/ansible/playbooks/ansible.cfg b/ironic_staging_drivers/ansible/playbooks/ansible.cfg new file mode 100644 index 0000000..e1b0e83 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/ansible.cfg @@ -0,0 +1,24 @@ +[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 + +[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_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.ini b/ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.ini new file mode 100644 index 0000000..e42a1c7 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.ini @@ -0,0 +1,8 @@ +[ironic] +# If Ironic's config is not in one of default oslo_config locations, +# specify the path to it here +#config_file = None + +# If running a testing system with only stderr logging (e.g. DevStack) +# specify an actual file to log into here, for example Ironic-Conductor one. +#log_file = None diff --git a/ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.py b/ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.py new file mode 100644 index 0000000..a0dbf51 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/callback_plugins/ironic_log.py @@ -0,0 +1,122 @@ +# +# 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 ironic.common import i18n +from ironic import version + + +basename = os.path.splitext(__file__)[0] +config = ConfigParser.ConfigParser() +ironic_config = None +ironic_log_file = None +try: + config.readfp(open(basename + ".ini")) + if config.has_option('ironic', 'config_file'): + ironic_config = config.get('ironic', 'config_file') + if config.has_option('ironic', 'log_file'): + ironic_log_file = config.get('ironic', 'log_file') +except Exception: + pass + +CONF = cfg.CONF +DOMAIN = 'ironic' +LOG = logging.getLogger(__name__, project=DOMAIN, + version=version.version_info.release_string()) +logging.register_options(CONF) + +conf_kwargs = dict(args=[], project=DOMAIN, + version=version.version_info.release_string()) +if ironic_config: + conf_kwargs['default_config_files'] = [ironic_config] +CONF(**conf_kwargs) + +if ironic_log_file: + CONF.set_override("log_file", ironic_log_file) +CONF.set_override("use_stderr", False) + +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): + self.node = None + + 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': + LOG.debug("Processing task %(name)s.", dict(name=name)) + else: + LOG.debug("Processing task %(name)s on node %(node)s.", + dict(name=name, node=node)) + + def v2_runner_on_failed(self, result, *args, **kwargs): + LOG.error(i18n._LE( + "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': + LOG.info(i18n._LI( + "Ansible task 'setup' complete on node %(node)s"), + msg_dict) + else: + LOG.info(i18n._LI( + "Ansible task %(name)s complete on node %(node)s: %(res)s"), + msg_dict) + + def v2_runner_on_unreachable(self, result): + LOG.error(i18n._LE( + "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): + 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): + LOG.info(i18n._LI( + "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): + LOG.error(i18n._LE( + "Async Ansible task %(name)s failed on node %(node)s: %(res)s"), + self.runner_msg_dict(result)) + + def v2_runner_on_skipped(self, result): + LOG.debug("Ansible task %(name)s skipped on node %(node)s: %(res)s", + self.runner_msg_dict(result)) diff --git a/ironic_staging_drivers/ansible/playbooks/clean.yaml b/ironic_staging_drivers/ansible/playbooks/clean.yaml new file mode 100644 index 0000000..0d40bdd --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/clean.yaml @@ -0,0 +1,12 @@ +--- +- include: add-ironic-nodes.yaml + +- hosts: ironic + gather_facts: no + roles: + - role: wait + tags: wait + +- hosts: ironic + roles: + - clean diff --git a/ironic_staging_drivers/ansible/playbooks/clean_steps.yaml b/ironic_staging_drivers/ansible/playbooks/clean_steps.yaml new file mode 100644 index 0000000..b404819 --- /dev/null +++ b/ironic_staging_drivers/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_staging_drivers/ansible/playbooks/deploy.yaml b/ironic_staging_drivers/ansible/playbooks/deploy.yaml new file mode 100644 index 0000000..517b78c --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/deploy.yaml @@ -0,0 +1,13 @@ +--- +- include: add-ironic-nodes.yaml + +- hosts: ironic + gather_facts: no + roles: + - role: wait + tags: wait + +- hosts: ironic + roles: + - deploy + - shutdown diff --git a/ironic_staging_drivers/ansible/playbooks/inventory b/ironic_staging_drivers/ansible/playbooks/inventory new file mode 100644 index 0000000..f6599ef --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/inventory @@ -0,0 +1 @@ +conductor ansible_connection=local diff --git a/ironic_staging_drivers/ansible/playbooks/library/parted.py b/ironic_staging_drivers/ansible/playbooks/library/parted.py new file mode 100644 index 0000000..4d63b84 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/library/parted.py @@ -0,0 +1,111 @@ +#!/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. + + +PARTITION_TYPES = ('primary', 'logical', 'extended') + + +def construct_parted_args(device): + + parted_args = [ + '-s', device['device'], + ] + if device['label']: + parted_args.extend(['mklabel', device['label']]) + + partitions = device['partitions'] + if partitions: + parted_args.extend(['-a', 'optimal', '--', 'unit', 'MiB']) + start = 1 + for ind, partition in enumerate(device['partitions']): + parted_args.extend([ + 'mkpart', partition['type']]) + if partition['swap']: + parted_args.append('linux-swap') + end = start + partition['size_mib'] + parted_args.extend(["%i" % start, "%i" % end]) + start = end + if partition['boot']: + parted_args.extend([ + 'set', str(ind + 1), 'boot', 'on']) + + return parted_args + + +def validate_partitions(module, partitions): + for ind, partition in enumerate(partitions): + # partition name might be an empty string + partition['name'] = partition.get('name') or str(ind + 1) + size = partition.get('size_mib', None) + if not size: + module.fail_json(msg="Partition size must be provided") + try: + partition['size_mib'] = int(size) + except ValueError: + module.fail_json(msg="Can not cast partition size to INT.") + partition.setdefault('type', 'primary') + if partition['type'] not in PARTITION_TYPES: + module.fail_json(msg="Partition type must be one of " + "%s." % PARTITION_TYPES) + partition['swap'] = module.boolean(partition.get('swap', False)) + partition['boot'] = module.boolean(partition.get('boot', False)) + if partition['boot'] and partition['swap']: + module.fail_json(msg="Can not set partition to " + "boot and swap simultaneously.") + # TODO(pas-ha) add more validation, e.g. + # - only one boot partition? + # - no more than 4 primary partitions on msdos table + # - no more that one extended partition on msdos table + # - estimate and validate available space + + +def main(): + module = AnsibleModule( + argument_spec=dict( + device=dict(required=True, type='str'), + dryrun=dict(required=False, default=False, type='bool'), + new_label=dict(required=False, default=False, type='bool'), + label=dict(requred=False, default='msdos', choices=[ + "bsd", "dvh", "gpt", "loop", "mac", "msdos", "pc98", "sun"]), + partitions=dict( + required=False, type='list') + ), + supports_check_mode=True) + + device = module.params['device'] + dryrun = module.params['dryrun'] + new_label = module.params['new_label'] + label = module.params['label'] + if not new_label: + label = False + partitions = module.params['partitions'] or [] + try: + validate_partitions(module, partitions) + except Exception as e: + module.fail_json(msg="Malformed partitions arguments: %s" % e) + parted_args = construct_parted_args(dict(device=device, label=label, + partitions=partitions)) + command = [module.get_bin_path('parted', required=True)] + if not (module.check_mode or dryrun): + command.extend(parted_args) + module.run_command(command, check_rc=True) + partitions_created = {p['name']: '%s%i' % (device, i + 1) + for i, p in enumerate(partitions)} + module.exit_json(changed=not dryrun, created=partitions_created) + + +from ansible.module_utils.basic import * # noqa +if __name__ == '__main__': + main() diff --git a/ironic_staging_drivers/ansible/playbooks/library/stream_url.py b/ironic_staging_drivers/ansible/playbooks/library/stream_url.py new file mode 100644 index 0000000..10a1a68 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/library/stream_url.py @@ -0,0 +1,104 @@ +#!/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): + 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) + 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): + downloader = StreamingDownloader(url, chunksize, hash_algo) + + 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) + )) + + url = module.params['url'] + dest = module.params['dest'] + checksum = module.params['checksum'] + chunksize = module.params['chunksize'] + 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) + 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_staging_drivers/ansible/playbooks/roles/clean/tasks/main.yaml b/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/main.yaml new file mode 100644 index 0000000..aae5389 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/main.yaml @@ -0,0 +1,6 @@ +- include: zap.yaml + tags: + - zap +- include: shred.yaml + tags: + - shred diff --git a/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/shred.yaml b/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/shred.yaml new file mode 100644 index 0000000..8b5aa0d --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/shred.yaml @@ -0,0 +1,6 @@ +- name: clean block devices + become: yes + command: shred -f -z /dev/{{ item }} + async: 3600 + poll: 30 + with_items: "{{ ansible_devices }}" diff --git a/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/zap.yaml b/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/zap.yaml new file mode 100644 index 0000000..6f97cfd --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/clean/tasks/zap.yaml @@ -0,0 +1,4 @@ +- name: wipe partition metadata + become: yes + command: sgdisk -Z /dev/{{ item }} + with_items: "{{ ansible_devices }}" diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/files/install_grub.sh b/ironic_staging_drivers/ansible/playbooks/roles/deploy/files/install_grub.sh new file mode 100755 index 0000000..d19044a --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/files/install_grub.sh @@ -0,0 +1,54 @@ +#!/bin/sh + +# code from DIB bash ramdisk +readonly target_disk=$1 +readonly root_part=$2 +readonly root_part_mount=/mnt/rootfs + +# We need to run partprobe to ensure all partitions are visible +partprobe $target_disk + +mkdir -p $root_part_mount + +mount $root_part $root_part_mount +if [ $? != "0" ]; then + echo "Failed to mount root partition $root_part on $root_part_mount" + exit 1 +fi + +mkdir -p $root_part_mount/dev +mkdir -p $root_part_mount/sys +mkdir -p $root_part_mount/proc + +mount -o bind /dev $root_part_mount/dev +mount -o bind /sys $root_part_mount/sys +mount -o bind /proc $root_part_mount/proc + +# Find grub version +V= +if [ -x $root_part_mount/usr/sbin/grub2-install ]; then + V=2 +fi + +# Install grub +ret=1 +if chroot $root_part_mount /bin/sh -c "/usr/sbin/grub$V-install ${target_disk}"; then + echo "Generating the grub configuration file" + + # tell GRUB2 to preload its "lvm" module to gain LVM booting on direct-attached disks + if [ "$V" = "2" ]; then + echo "GRUB_PRELOAD_MODULES=lvm" >> $root_part_mount/etc/default/grub + fi + chroot $root_part_mount /bin/sh -c "/usr/sbin/grub$V-mkconfig -o /boot/grub$V/grub.cfg" + ret=$? +fi + +umount $root_part_mount/dev +umount $root_part_mount/sys +umount $root_part_mount/proc +umount $root_part_mount + +if [ $ret != "0" ]; then + echo "Installing grub bootloader failed" +fi +exit $ret diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/files/partition_configdrive.sh b/ironic_staging_drivers/ansible/playbooks/roles/deploy/files/partition_configdrive.sh new file mode 100755 index 0000000..00fa742 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/files/partition_configdrive.sh @@ -0,0 +1,115 @@ +#!/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, +# all logging is disabled to let Ansible output the full trace. +# The places that log to fail are commented out to be replaced later +# with different handler when making this script a real Ansible module + +# 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 [ $? = 0 ]; then + #log "Existing configdrive found on ${DEVICE} at ${EXISTING_PARTITION}" + ISO_PARTITION=$EXISTING_PARTITION +else + + # Check if it is GPT partition and needs to be re-sized + partprobe $DEVICE print 2>&1 | grep "fix the GPT to use all of the space" + if [ $? = 0 ]; 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 ext2 $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 +fi + +# Output the created/discovered partition for configdrive +echo "configdrive $ISO_PARTITION" diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/configdrive.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/configdrive.yaml new file mode 100644 index 0000000..ed77610 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/configdrive.yaml @@ -0,0 +1,37 @@ +- name: download configdrive data + get_url: + url: "{{ configdrive.location }}" + dest: /tmp/{{ inventory_hostname }}.gz.base64 + async: 600 + poll: 15 + when: "{{ configdrive.type|default('') == 'url' }}" + +- block: + - name: copy configdrive file to node + copy: + src: "{{ configdrive.location }}" + dest: /tmp/{{ inventory_hostname }}.gz.base64 + - name: remove configdrive from conductor + delegate_to: conductor + file: + path: "{{ configdrive.location }}" + state: absent + when: "{{ configdrive.type|default('') == 'file' }}" + +- name: unpack configdrive + shell: cat /tmp/{{ inventory_hostname }}.gz.base64 | base64 --decode | gunzip > /tmp/{{ inventory_hostname }}.cndrive + +- 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: write configdrive + become: yes + command: dd if=/tmp/{{ inventory_hostname }}.cndrive of={{ (configdrive_partition_output.stdout_lines | last).split() | last }} bs=64K oflag=direct diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/download.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/download.yaml new file mode 100644 index 0000000..f979679 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/download.yaml @@ -0,0 +1,11 @@ +- name: fail if not enough memory to store downloaded image + fail: + msg: "The image size is too big, no free memory available" + when: "{{ ansible_memfree_mb }} < {{ image.mem_req }}" +- name: download image with checksum validation + get_url: + url: "{{ image.url }}" + dest: /tmp/{{ inventory_hostname }}.img + checksum: "{{ image.checksum|default(omit) }}" + async: 600 + poll: 15 diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/grub.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/grub.yaml new file mode 100644 index 0000000..ce6308b --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/grub.yaml @@ -0,0 +1,3 @@ +- name: configure bootloader + become: yes + script: install_grub.sh {{ ironic_root_device }} {{ ironic_image_target }} diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/main.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/main.yaml new file mode 100644 index 0000000..68f11cc --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/main.yaml @@ -0,0 +1,17 @@ +- include: root-device.yaml + +- include: parted.yaml + tags: + - parted + +- include: download.yaml + when: "{{ image.disk_format != 'raw' }}" + +- include: write.yaml + +- include: configdrive.yaml + when: configdrive is defined + +- include: grub.yaml + tags: + - parted diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/parted.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/parted.yaml new file mode 100644 index 0000000..2b908e5 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/parted.yaml @@ -0,0 +1,28 @@ +- name: erase partition table + become: yes + command: dd if=/dev/zero of={{ ironic_root_device }} bs=512 count=36 + when: "{{ not preserve_ephemeral|default('no')|bool }}" + +- name: run parted + become: yes + parted: + device: "{{ ironic_root_device }}" + dryrun: "{{ preserve_ephemeral|default('no')|bool }}" + new_label: yes + label: msdos + partitions: "{{ ironic_partitions }}" + register: parts + +- name: reset image target to root partition + set_fact: + ironic_image_target: "{{ parts.created.root }}" + +- name: make swap + become: yes + command: mkswap -L swap1 {{ parts.created.swap }} + when: "{{ parts.created.swap is defined }}" + +- name: format ephemeral partition + become: yes + command: mkfs -F -t {{ ephemeral_format }} -L ephemeral0 {{ parts.created.ephemeral }} + when: "{{ parts.created.ephemeral is defined and not preserve_ephemeral|default('no')|bool }}" diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/root-device.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/root-device.yaml new file mode 100644 index 0000000..6f88623 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/root-device.yaml @@ -0,0 +1,7 @@ +- set_fact: + ironic_root_device: /dev/{{ ansible_devices.keys()[0] }} + when: ironic_root_device is undefined + +- set_fact: + ironic_image_target: "{{ ironic_root_device }}" + when: ironic_image_target is undefined diff --git a/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/write.yaml b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/write.yaml new file mode 100644 index 0000000..c2d5c30 --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/deploy/tasks/write.yaml @@ -0,0 +1,19 @@ +- name: convert and write + become: yes + command: qemu-img convert -t directsync -O host_device /tmp/{{ inventory_hostname }}.img {{ ironic_image_target }} + async: 400 + poll: 10 + when: "{{ image.disk_format != 'raw' }}" + +- name: stream to target + become: yes + stream_url: + url: "{{ image.url }}" + dest: "{{ ironic_image_target }}" + checksum: "md5:{{ image.checksum }}" + async: 600 + poll: 15 + when: "{{ image.disk_format == 'raw' }}" + +- name: flush + command: sync diff --git a/ironic_staging_drivers/ansible/playbooks/roles/shutdown/tasks/main.yaml b/ironic_staging_drivers/ansible/playbooks/roles/shutdown/tasks/main.yaml new file mode 100644 index 0000000..3172f5d --- /dev/null +++ b/ironic_staging_drivers/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_staging_drivers/ansible/playbooks/roles/wait/tasks/main.yaml b/ironic_staging_drivers/ansible/playbooks/roles/wait/tasks/main.yaml new file mode 100644 index 0000000..db6b9ce --- /dev/null +++ b/ironic_staging_drivers/ansible/playbooks/roles/wait/tasks/main.yaml @@ -0,0 +1,10 @@ +- name: waiting for node + become: false + delegate_to: conductor + wait_for: + host: "{{ ansible_ssh_host }}" + port: 22 + search_regex: OpenSSH + delay: 10 + timeout: 400 + connect_timeout: 15 diff --git a/ironic_staging_drivers/ansible/python-requirements.txt b/ironic_staging_drivers/ansible/python-requirements.txt new file mode 100644 index 0000000..b4cd4ca --- /dev/null +++ b/ironic_staging_drivers/ansible/python-requirements.txt @@ -0,0 +1 @@ +ansible>=2.1 diff --git a/ironic_staging_drivers/tests/unit/ansible/__init__.py b/ironic_staging_drivers/tests/unit/ansible/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ironic_staging_drivers/tests/unit/ansible/test_deploy.py b/ironic_staging_drivers/tests/unit/ansible/test_deploy.py new file mode 100644 index 0000000..d2487b2 --- /dev/null +++ b/ironic_staging_drivers/tests/unit/ansible/test_deploy.py @@ -0,0 +1,778 @@ +# 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 json +import os + +from ironic.common import dhcp_factory +from ironic.common import exception +from ironic.common import image_service +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 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 +from ironic_lib import utils as irlib_utils +import mock +from oslo_config import cfg + +from ironic_staging_drivers.ansible import deploy as ansible_deploy + +CONF = cfg.CONF + + +INSTANCE_INFO = { + 'image_source': 'fake-image', + 'image_url': 'http://image', + 'image_checksum': 'checksum', + 'image_disk_format': 'qcow2', + 'root_gb': 5, +} + +DRIVER_INFO = { + 'deploy_kernel': 'glance://deploy_kernel_uuid', + 'deploy_ramdisk': 'glance://deploy_ramdisk_uuid', + 'ansible_deploy_username': 'test', + 'ansible_deploy_key_file': '/path/key', +} +DRIVER_INTERNAL_INFO = { + 'ansible_cleaning_ip': 'http://127.0.0.1/', + 'is_whole_disk_image': True, + 'clean_steps': [] +} + + +class TestAnsibleMethods(db_base.DbTestCase): + def setUp(self): + super(TestAnsibleMethods, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_ansible') + node = { + 'driver': 'fake_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) + + 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') + + @mock.patch.object(image_service, 'GlanceImageService', autospec=True) + def test_build_instance_info_for_deploy_glance_image(self, glance_mock): + i_info = self.node.instance_info + i_info['image_source'] = '733d1c44-a2ea-414b-aca7-69decf20d810' + self.node.instance_info = i_info + self.node.save() + + image_info = {'checksum': 'aa', 'disk_format': 'qcow2'} + glance_mock.return_value.show = mock.Mock(spec_set=[], + return_value=image_info) + + with task_manager.acquire( + self.context, self.node.uuid) as task: + + ansible_deploy.build_instance_info_for_deploy(task) + + glance_mock.assert_called_once_with(version=2, + context=task.context) + glance_mock.return_value.show.assert_called_once_with( + self.node.instance_info['image_source']) + glance_mock.return_value.swift_temp_url.assert_called_once_with( + image_info) + + @mock.patch.object(image_service.HttpImageService, 'validate_href', + autospec=True) + def test_build_instance_info_for_deploy_nonglance_image( + self, validate_href_mock): + i_info = self.node.instance_info + driver_internal_info = self.node.driver_internal_info + i_info['image_source'] = 'http://image-ref' + i_info['image_checksum'] = 'aa' + i_info['root_gb'] = 10 + driver_internal_info['is_whole_disk_image'] = True + self.node.instance_info = i_info + self.node.driver_internal_info = driver_internal_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + info = ansible_deploy.build_instance_info_for_deploy(task) + + self.assertEqual(self.node.instance_info['image_source'], + info['image_url']) + validate_href_mock.assert_called_once_with( + mock.ANY, 'http://image-ref') + + @mock.patch.object(image_service.HttpImageService, 'validate_href', + autospec=True) + def test_build_instance_info_for_deploy_nonsupported_image( + self, validate_href_mock): + validate_href_mock.side_effect = iter( + [exception.ImageRefValidationFailed( + image_href='file://img.qcow2', reason='fail')]) + i_info = self.node.instance_info + i_info['image_source'] = 'file://img.qcow2' + i_info['image_checksum'] = 'aa' + self.node.instance_info = i_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises( + exception.ImageRefValidationFailed, + ansible_deploy.build_instance_info_for_deploy, task) + + def test__get_node_ip(self): + dhcp_provider_mock = mock.Mock() + dhcp_factory.DHCPFactory._dhcp_provider = dhcp_provider_mock + dhcp_provider_mock.get_ip_addresses.return_value = ['ip'] + with task_manager.acquire(self.context, self.node.uuid) as task: + ansible_deploy._get_node_ip(task) + dhcp_provider_mock.get_ip_addresses.assert_called_once_with( + task) + + def test__get_node_ip_no_ip(self): + dhcp_provider_mock = mock.Mock() + dhcp_factory.DHCPFactory._dhcp_provider = dhcp_provider_mock + dhcp_provider_mock.get_ip_addresses.return_value = [] + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.FailedToGetIPAddressOnPort, + ansible_deploy._get_node_ip, task) + + def test__get_node_ip_multiple_ip(self): + dhcp_provider_mock = mock.Mock() + dhcp_factory.DHCPFactory._dhcp_provider = dhcp_provider_mock + dhcp_provider_mock.get_ip_addresses.return_value = ['ip1', 'ip2'] + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InstanceDeployFailure, + ansible_deploy._get_node_ip, task) + + @mock.patch.object(utils, 'node_power_action', autospec=True) + @mock.patch.object(fake.FakePower, 'get_power_state', + return_value=states.POWER_OFF) + def test__reboot_and_finish_deploy(self, get_pow_state_mock, + power_action_mock): + self.config(group='ansible', + post_deploy_get_power_state_retry_interval=0) + + with task_manager.acquire(self.context, self.node.uuid) as task: + ansible_deploy._reboot_and_finish_deploy(task) + get_pow_state_mock.assert_called_once_with(task) + power_action_mock.assert_called_once_with(task, states.POWER_ON) + + @mock.patch.object(utils, 'node_power_action', autospec=True) + @mock.patch.object(fake.FakePower, 'get_power_state', + return_value=states.POWER_ON) + def test__reboot_and_finish_deploy_retry(self, get_pow_state_mock, + power_action_mock): + self.config(group='ansible', + post_deploy_get_power_state_retry_interval=0) + + with task_manager.acquire(self.context, self.node.uuid) as task: + ansible_deploy._reboot_and_finish_deploy(task) + get_pow_state_mock.assert_called_with(task) + self.assertEqual( + CONF.ansible.post_deploy_get_power_state_retries + 1, + len(get_pow_state_mock.mock_calls)) + expected_power_calls = [((task, states.POWER_OFF),), + ((task, states.POWER_ON),)] + self.assertEqual(expected_power_calls, + power_action_mock.call_args_list) + + @mock.patch.object(com_utils, 'execute', return_value=('out', 'err'), + autospec=True) + @mock.patch.object(os.path, 'join', return_value='/path/to/playbook', + autospec=True) + def test__run_playbook(self, path_join_mock, execute_mock): + extra_vars = {"ironic_nodes": [{"name": self.node["uuid"], + "ip": "127.0.0.1", "user": "test"}]} + + ansible_deploy._run_playbook('deploy', extra_vars, '/path/to/key') + + execute_mock.assert_called_once_with( + 'env', 'ANSIBLE_CONFIG=%s' % CONF.ansible.config_file_path, + 'ansible-playbook', '/path/to/playbook', '-i', + ansible_deploy.INVENTORY_FILE, '-e', json.dumps(extra_vars), + '--private-key=/path/to/key', '-vvvv') + + @mock.patch.object(com_utils, 'execute', return_value=('out', 'err'), + autospec=True) + @mock.patch.object(os.path, 'join', return_value='/path/to/playbook', + autospec=True) + def test__run_playbook_tags(self, path_join_mock, execute_mock): + extra_vars = {"ironic_nodes": [{"name": self.node["uuid"], + "ip": "127.0.0.1", "user": "test"}]} + + ansible_deploy._run_playbook('deploy', extra_vars, '/path/to/key', + tags=['wait']) + + execute_mock.assert_called_once_with( + 'env', 'ANSIBLE_CONFIG=%s' % CONF.ansible.config_file_path, + 'ansible-playbook', '/path/to/playbook', '-i', + ansible_deploy.INVENTORY_FILE, '-e', json.dumps(extra_vars), + '--tags=wait', '--private-key=/path/to/key', '-vvvv') + + @mock.patch.object(deploy_utils, 'check_for_missing_params', + autospec=True) + def test__parse_partitioning_info(self, check_missing_param_mock): + expected_info = { + 'ironic_partitions': + [{'boot': 'yes', 'swap': 'no', + 'size_mib': 1024 * INSTANCE_INFO['root_gb'], + 'name': 'root'}]} + + i_info = ansible_deploy._parse_partitioning_info(self.node) + + check_missing_param_mock.assert_called_once_with( + expected_info, mock.ANY) + self.assertEqual(expected_info, i_info) + + @mock.patch.object(deploy_utils, 'check_for_missing_params', + autospec=True) + def test__parse_partitioning_info_swap(self, check_missing_param_mock): + in_info = dict(INSTANCE_INFO) + in_info['swap_mb'] = 128 + self.node.instance_info = in_info + self.node.save() + + expected_info = { + 'ironic_partitions': + [{'boot': 'yes', 'swap': 'no', + 'size_mib': 1024 * INSTANCE_INFO['root_gb'], + 'name': 'root'}, + {'boot': 'no', 'swap': 'yes', + 'size_mib': 128, 'name': 'swap'}]} + + i_info = ansible_deploy._parse_partitioning_info(self.node) + + check_missing_param_mock.assert_called_once_with( + expected_info, mock.ANY) + self.assertEqual(expected_info, i_info) + + @mock.patch.object(deploy_utils, 'check_for_missing_params', + autospec=True) + def test__parse_partitioning_info_invalid_param(self, + check_missing_param_mock): + in_info = dict(INSTANCE_INFO) + in_info['root_gb'] = 'five' + self.node.instance_info = in_info + self.node.save() + + self.assertRaises(exception.InvalidParameterValue, + ansible_deploy._parse_partitioning_info, + self.node) + + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk') + @mock.patch.object(ansible_deploy, '_reboot_and_finish_deploy', + autospec=True) + @mock.patch.object(utils, 'node_set_boot_device', autospec=True) + @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__deploy(self, prepare_vars_mock, parse_part_info_mock, + parse_dr_info_mock, prepare_extra_mock, + run_playbook_mock, set_boot_device_mock, + finish_deploy_mock, clean_ramdisk_mock): + ironic_nodes = { + 'ironic_nodes': [(self.node['uuid'], + DRIVER_INTERNAL_INFO['ansible_cleaning_ip'], + '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: + ansible_deploy._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': [ + (self.node['uuid'], + DRIVER_INTERNAL_INFO['ansible_cleaning_ip'], + 'test_u')]}, 'test_k', + notags=['wait']) + set_boot_device_mock.assert_called_once_with( + task, 'disk', persistent=True) + finish_deploy_mock.assert_called_once_with(task) + clean_ramdisk_mock.assert_called_once_with(task) + + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk') + @mock.patch.object(ansible_deploy, '_reboot_and_finish_deploy', + autospec=True) + @mock.patch.object(utils, 'node_set_boot_device', autospec=True) + @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__deploy_iwdi(self, prepare_vars_mock, parse_part_info_mock, + parse_dr_info_mock, prepare_extra_mock, + run_playbook_mock, set_boot_device_mock, + finish_deploy_mock, clean_ramdisk_mock): + ironic_nodes = { + 'ironic_nodes': [(self.node['uuid'], + DRIVER_INTERNAL_INFO['ansible_cleaning_ip'], + '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: + ansible_deploy._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': [ + (self.node['uuid'], + DRIVER_INTERNAL_INFO['ansible_cleaning_ip'], + 'test_u')]}, 'test_k', + notags=['wait', 'parted']) + set_boot_device_mock.assert_called_once_with( + task, 'disk', persistent=True) + finish_deploy_mock.assert_called_once_with(task) + clean_ramdisk_mock.assert_called_once_with(task) + + +class TestAnsibleDeploy(db_base.DbTestCase): + def setUp(self): + super(TestAnsibleDeploy, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_ansible') + self.driver = ansible_deploy.AnsibleDeploy() + node = { + 'driver': 'fake_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) + + def test_get_properties(self): + self.assertEqual(ansible_deploy.COMMON_PROPERTIES, + 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_wait(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(ansible_deploy, '_deploy', autospec=True) + @mock.patch.object(ansible_deploy, '_get_node_ip', + return_value='127.0.0.1', autospec=True) + @mock.patch.object(utils, 'node_power_action', autospec=True) + def test_deploy_done(self, power_mock, get_ip_mock, deploy_mock): + self.config(group='ansible', use_ramdisk_callback=False) + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + driver_return = self.driver.deploy(task) + self.assertEqual(driver_return, states.DEPLOYDONE) + power_mock.assert_called_once_with(task, states.REBOOT) + get_ip_mock.assert_called_once_with(task) + deploy_mock.assert_called_once_with(task, '127.0.0.1') + + @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.drivers.modules.deploy_utils.build_agent_options', + return_value={'op1': 'test1'}, autospec=True) + @mock.patch.object(ansible_deploy, '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): + with task_manager.acquire( + self.context, self.node['uuid'], shared=False) as task: + task.node.provision_state = states.DEPLOYING + + self.driver.prepare(task) + + 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, 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, 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'], + DRIVER_INTERNAL_INFO['ansible_cleaning_ip'], + 'test_u', {})]} + prepare_extra_mock.return_value = ironic_nodes + + 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, '_run_playbook', 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_no_ip(self, parse_driver_info_mock, + run_playbook_mock): + + step = {'priority': 10, 'interface': 'deploy', + 'step': 'erase_devices', 'tags': ['clean']} + driver_internal_info = dict(DRIVER_INTERNAL_INFO) + del driver_internal_info['ansible_cleaning_ip'] + self.node.driver_internal_info = driver_internal_info + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.NodeCleaningFailure, + self.driver.execute_clean_step, task, step) + + parse_driver_info_mock.assert_called_once_with( + task.node, action='clean') + self.assertFalse(run_playbook_mock.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') + @mock.patch.object(deploy_utils, 'prepare_cleaning_ports', autospec=True) + def test_prepare_cleaning_callback( + self, prepare_cleaning_ports_mock, 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: + state = self.driver.prepare_cleaning(task) + + set_node_cleaning_steps.assert_called_once_with(task) + prepare_cleaning_ports_mock.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) + @mock.patch.object(deploy_utils, 'prepare_cleaning_ports', autospec=True) + def test_prepare_cleaning_callback_no_steps(self, + prepare_cleaning_ports_mock, + set_node_cleaning_steps): + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.prepare_cleaning(task) + + set_node_cleaning_steps.assert_called_once_with(task) + self.assertFalse(prepare_cleaning_ports_mock.called) + + @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, '_get_node_ip', + return_value='127.0.0.1', autospec=True) + @mock.patch.object(ansible_deploy, '_run_playbook', 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') + @mock.patch.object(deploy_utils, 'prepare_cleaning_ports', autospec=True) + def test_prepare_cleaning(self, prepare_cleaning_ports_mock, + prepare_ramdisk_mock, buid_options_mock, + power_action_mock, run_playbook_mock, + get_ip_mock, parse_driver_info_mock, + prepare_extra_mock): + self.config(group='ansible', use_ramdisk_callback=False) + ironic_nodes = { + 'ironic_nodes': [(self.node['uuid'], + '127.0.0.1', + 'test_u', {})]} + prepare_extra_mock.return_value = ironic_nodes + + with task_manager.acquire(self.context, self.node.uuid) as task: + state = self.driver.prepare_cleaning(task) + + prepare_cleaning_ports_mock.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) + get_ip_mock.assert_called_once_with(task) + 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=['wait']) + self.assertEqual(None, state) + + @mock.patch.object(utils, 'node_power_action', autospec=True) + @mock.patch.object(pxe.PXEBoot, 'clean_up_ramdisk') + @mock.patch.object(deploy_utils, 'tear_down_cleaning_ports', + autospec=True) + def test_tear_down_cleaning(self, tear_down_utils_mock, + clean_ramdisk_mock, power_action_mock): + with task_manager.acquire(self.context, self.node.uuid) as task: + 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) + tear_down_utils_mock.assert_called_once_with(task) + + @mock.patch.object(ansible_deploy, 'LOG', autospec=True) + def test_heartbeat_not_wait_state(self, log_mock): + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.heartbeat(task, 'http://127.0.0.1') + log_mock.warning.assert_called_once_with( + mock.ANY, {'node': task.node['uuid'], + 'state': task.node['provision_state']}) + + @mock.patch.object(ansible_deploy, 'LOG', autospec=True) + @mock.patch.object(ansible_deploy, '_deploy', autospec=True) + def test_heartbeat_deploy_wait(self, deploy_mock, log_mock): + self.node['provision_state'] = states.DEPLOYWAIT + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + task.process_event = mock.Mock() + + self.driver.heartbeat(task, 'http://127.0.0.1') + + deploy_mock.assert_called_once_with(task, '127.0.0.1') + log_mock.info.assert_called_once_with(mock.ANY, task.node['uuid']) + self.assertEqual([mock.call('resume'), mock.call('done')], + task.process_event.mock_calls) + + @mock.patch.object(deploy_utils, 'set_failed_state', autospec=True) + @mock.patch.object(ansible_deploy, 'LOG', autospec=True) + @mock.patch.object(ansible_deploy, '_deploy', + side_effect=Exception('Boo'), autospec=True) + def test_heartbeat_deploy_wait_fail(self, deploy_mock, log_mock, + set_fail_state_mock): + self.node['provision_state'] = states.DEPLOYWAIT + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + task.process_event = mock.Mock() + + self.driver.heartbeat(task, 'http://127.0.0.1') + + deploy_mock.assert_called_once_with(task, '127.0.0.1') + log_mock.exception.assert_called_once_with(mock.ANY) + self.assertEqual([mock.call('resume')], + task.process_event.mock_calls) + set_fail_state_mock.assert_called_once_with(task, mock.ANY, + collect_logs=False) + + @mock.patch.object(ansible_deploy, '_notify_conductor_resume_clean', + autospec=True) + def test_heartbeat_clean_wait(self, notify_resume_clean_mock): + self.node['provision_state'] = states.CLEANWAIT + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + task.process_event = mock.Mock() + + self.driver.heartbeat(task, 'http://127.0.0.1') + + notify_resume_clean_mock.assert_called_once_with(task) + + @mock.patch.object(ansible_deploy, '_notify_conductor_resume_clean', + side_effect=Exception('Boo'), autospec=True) + @mock.patch.object(utils, 'cleaning_error_handler', autospec=True) + def test_heartbeat_clean_wait_fail(self, cleaning_error_mock, + notify_resume_clean_mock): + self.node['provision_state'] = states.CLEANWAIT + self.node.save() + + with task_manager.acquire(self.context, self.node.uuid) as task: + task.process_event = mock.Mock() + + self.driver.heartbeat(task, 'http://127.0.0.1') + + notify_resume_clean_mock.assert_called_once_with(task) + cleaning_error_mock.assert_called_once_with(task, mock.ANY) + + @mock.patch.object(ansible_deploy, '_notify_conductor_resume_clean', + autospec=True) + @mock.patch.object(ansible_deploy, '_deploy', autospec=True) + @mock.patch.object(ansible_deploy, 'LOG', autospec=True) + def test_heartbeat_maintenance(self, log_mock, deploy_mock, + notify_clean_resume_mock): + self.node['maintenance'] = True + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.heartbeat(task, 'http://127.0.0.1') + + self.node['provision_state'] = states.CLEANWAIT + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.heartbeat(task, 'http://127.0.0.1') + + self.node['provision_state'] = states.DEPLOYWAIT + self.node.save() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.driver.heartbeat(task, 'http://127.0.0.1') + + self.assertFalse(log_mock.warning.called) + self.assertFalse(deploy_mock.called) + self.assertFalse(notify_clean_resume_mock.called) diff --git a/releasenotes/notes/ansible-deploy-63d94ae3857bf7d0.yaml b/releasenotes/notes/ansible-deploy-63d94ae3857bf7d0.yaml new file mode 100644 index 0000000..063a4bf --- /dev/null +++ b/releasenotes/notes/ansible-deploy-63d94ae3857bf7d0.yaml @@ -0,0 +1,22 @@ +--- +features: + - | + Added Ansible-deploy driver. + + Supported features of Agent-based deploy drivers + + * Network separation + * Whole disk images + * Partition images with localboot + * Cleaning, both automated and manual with cleaning steps + * Configdrive partition, for both whole disk and partition images. + + Main known shortcomings in comparison with IPA-based drivers + + * Is not asynchronous + * Clean steps can not be aborted + * Does not support UEFI/secure/trusted boot (support is planned) + * Does not support root_device_hints (support is planned) + * Does not honor partition type capability for partiton images + (always msdos, gpt support is planned) + * Does not support partition images with netboot diff --git a/setup.cfg b/setup.cfg index 8987a05..d62ed7e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,10 @@ ironic.drivers = fake_iboot_fake = ironic_staging_drivers.iboot:FakeIBootFakeDriver pxe_iboot_iscsi = ironic_staging_drivers.iboot:PXEIBootISCSIDriver pxe_iboot_agent = ironic_staging_drivers.iboot:PXEIBootAgentDriver + fake_ansible = ironic_staging_drivers.ansible:FakeAnsibleDriver + pxe_ipmitool_ansible = ironic_staging_drivers.ansible:AnsibleAndIPMIToolDriver + pxe_ssh_ansible = ironic_staging_drivers.ansible:AnsibleAndSSHDriver + pxe_libvirt_ansible = ironic_staging_drivers.ansible:AnsibleAndLibvirtDriver [build_sphinx] source-dir = doc/source