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