Completely switch to openstacksdk

Change-Id: I1729797fa03095d200c7334281915abc284b5732
This commit is contained in:
Dmitry Tantsur 2018-11-22 13:35:10 +01:00
parent 6de505be69
commit eee74d31b8
18 changed files with 1003 additions and 1445 deletions

View File

@ -4,9 +4,8 @@ fixtures==3.0.0
flake8-import-order==0.13 flake8-import-order==0.13
hacking==1.0.0 hacking==1.0.0
mock==2.0 mock==2.0
openstacksdk==0.17.0 openstacksdk==0.22.0
pbr==2.0.0 pbr==2.0.0
python-ironicclient==1.14.0
Pygments==2.2.0 Pygments==2.2.0
requests==2.18.4 requests==2.18.4
six==1.10.0 six==1.10.0

View File

@ -13,11 +13,15 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import contextlib
import json import json
import os import logging
import shutil
import tempfile from openstack.baremetal import configdrive
from metalsmith import _utils
LOG = logging.getLogger(__name__)
class InstanceConfig(object): class InstanceConfig(object):
@ -56,13 +60,12 @@ class InstanceConfig(object):
kwargs.setdefault('ssh_authorized_keys', self.ssh_keys) kwargs.setdefault('ssh_authorized_keys', self.ssh_keys)
self.users.append(kwargs) self.users.append(kwargs)
@contextlib.contextmanager def build_configdrive(self, node, hostname):
def build_configdrive_directory(self, node, hostname): """Make the config drive.
"""Build a configdrive from the provided information.
:param node: `Node` object. :param node: `Node` object.
:param hostname: instance hostname. :param hostname: instance hostname.
:return: a context manager yielding a directory with files :return: configdrive contents as a base64-encoded string.
""" """
# NOTE(dtantsur): CirrOS does not understand lists # NOTE(dtantsur): CirrOS does not understand lists
if isinstance(self.ssh_keys, list): if isinstance(self.ssh_keys, list):
@ -70,33 +73,25 @@ class InstanceConfig(object):
else: else:
ssh_keys = self.ssh_keys ssh_keys = self.ssh_keys
d = tempfile.mkdtemp() metadata = {'public_keys': ssh_keys,
try: 'uuid': node.id,
metadata = {'public_keys': ssh_keys, 'name': node.name,
'uuid': node.uuid, 'hostname': hostname,
'name': node.name, 'launch_index': 0,
'hostname': hostname, 'availability_zone': '',
'launch_index': 0, 'files': [],
'availability_zone': '', 'meta': {}}
'files': [], user_data = {}
'meta': {}} user_data_bin = None
user_data = {}
if self.users:
user_data['users'] = self.users
for version in ('2012-08-10', 'latest'): if self.users:
subdir = os.path.join(d, 'openstack', version) user_data['users'] = self.users
if not os.path.exists(subdir):
os.makedirs(subdir)
with open(os.path.join(subdir, 'meta_data.json'), 'w') as fp: if user_data:
json.dump(metadata, fp) user_data_bin = ("#cloud-config\n" + json.dumps(user_data)).encode(
'utf-8')
if user_data: LOG.debug('Generating configdrive tree for node %(node)s with '
with open(os.path.join(subdir, 'user_data'), 'w') as fp: 'metadata %(meta)s', {'node': _utils.log_res(node),
fp.write("#cloud-config\n") 'meta': metadata})
json.dump(user_data, fp) return configdrive.build(metadata, user_data_bin)
yield d
finally:
shutil.rmtree(d)

View File

@ -52,12 +52,12 @@ class DefaultFormat(object):
else: else:
message = "Unprovisioning started for node %(node)s" message = "Unprovisioning started for node %(node)s"
_print(message, node=_utils.log_node(node)) _print(message, node=_utils.log_res(node))
def show(self, instances): def show(self, instances):
for instance in instances: for instance in instances:
_print("Node %(node)s, current state is %(state)s", _print("Node %(node)s, current state is %(state)s",
node=_utils.log_node(instance.node), state=instance.state) node=_utils.log_res(instance.node), state=instance.state)
if instance.is_deployed: if instance.is_deployed:
ips = instance.ip_addresses() ips = instance.ip_addresses()

View File

@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from metalsmith import _os_api from metalsmith import _utils
_PROGRESS_STATES = frozenset(['deploying', 'wait call-back', _PROGRESS_STATES = frozenset(['deploying', 'wait call-back',
@ -30,15 +30,15 @@ _HEALTHY_STATES = _PROGRESS_STATES | _ACTIVE_STATES
class Instance(object): class Instance(object):
"""Instance status in metalsmith.""" """Instance status in metalsmith."""
def __init__(self, api, node): def __init__(self, connection, node):
self._api = api self._connection = connection
self._uuid = node.uuid self._uuid = node.id
self._node = node self._node = node
@property @property
def hostname(self): def hostname(self):
"""Node's hostname.""" """Node's hostname."""
return self._node.instance_info.get(_os_api.HOSTNAME_FIELD) return self._node.instance_info.get(_utils.GetNodeMixin.HOSTNAME_FIELD)
def ip_addresses(self): def ip_addresses(self):
"""Returns IP addresses for this instance. """Returns IP addresses for this instance.
@ -61,12 +61,12 @@ class Instance(object):
@property @property
def _is_deployed_by_metalsmith(self): def _is_deployed_by_metalsmith(self):
return _os_api.HOSTNAME_FIELD in self._node.instance_info return _utils.GetNodeMixin.HOSTNAME_FIELD in self._node.instance_info
@property @property
def is_healthy(self): def is_healthy(self):
"""Whether the node is not at fault or maintenance.""" """Whether the node is not at fault or maintenance."""
return self.state in _HEALTHY_STATES and not self._node.maintenance return self.state in _HEALTHY_STATES and not self._node.is_maintenance
def nics(self): def nics(self):
"""List NICs for this instance. """List NICs for this instance.
@ -75,10 +75,10 @@ class Instance(object):
with full representations of their networks. with full representations of their networks.
""" """
result = [] result = []
vifs = self._api.list_node_attached_ports(self.node) vifs = self._connection.baremetal.list_node_vifs(self.node)
for vif in vifs: for vif in vifs:
port = self._api.connection.network.get_port(vif.id) port = self._connection.network.get_port(vif)
port.network = self._api.connection.network.get_network( port.network = self._connection.network.get_network(
port.network_id) port.network_id)
result.append(port) result.append(port)
return result return result
@ -110,7 +110,7 @@ class Instance(object):
elif prov_state in _ERROR_STATES: elif prov_state in _ERROR_STATES:
return 'error' return 'error'
elif prov_state in _ACTIVE_STATES: elif prov_state in _ACTIVE_STATES:
if self._node.maintenance: if self._node.is_maintenance:
return 'maintenance' return 'maintenance'
else: else:
return 'active' return 'active'

View File

@ -26,7 +26,7 @@ LOG = logging.getLogger(__name__)
class NICs(object): class NICs(object):
"""Requested NICs.""" """Requested NICs."""
def __init__(self, api, node, nics): def __init__(self, connection, node, nics):
if nics is None: if nics is None:
nics = [] nics = []
@ -38,7 +38,7 @@ class NICs(object):
raise TypeError("Each NIC must be a dict got %s" % nic) raise TypeError("Each NIC must be a dict got %s" % nic)
self._node = node self._node = node
self._api = api self._connection = connection
self._nics = nics self._nics = nics
self._validated = None self._validated = None
self.created_ports = [] self.created_ports = []
@ -68,25 +68,26 @@ class NICs(object):
for nic_type, nic in self._validated: for nic_type, nic in self._validated:
if nic_type == 'network': if nic_type == 'network':
port = self._api.connection.network.create_port(**nic) port = self._connection.network.create_port(**nic)
self.created_ports.append(port.id) self.created_ports.append(port.id)
LOG.info('Created port %(port)s for node %(node)s with ' LOG.info('Created port %(port)s for node %(node)s with '
'%(nic)s', {'port': _utils.log_res(port), '%(nic)s', {'port': _utils.log_res(port),
'node': _utils.log_node(self._node), 'node': _utils.log_res(self._node),
'nic': nic}) 'nic': nic})
else: else:
port = nic port = nic
self._api.attach_port_to_node(self._node.uuid, port.id) self._connection.baremetal.attach_vif_to_node(self._node,
port.id)
LOG.info('Attached port %(port)s to node %(node)s', LOG.info('Attached port %(port)s to node %(node)s',
{'port': _utils.log_res(port), {'port': _utils.log_res(port),
'node': _utils.log_node(self._node)}) 'node': _utils.log_res(self._node)})
self.attached_ports.append(port.id) self.attached_ports.append(port.id)
def detach_and_delete_ports(self): def detach_and_delete_ports(self):
"""Detach attached port and delete previously created ones.""" """Detach attached port and delete previously created ones."""
detach_and_delete_ports(self._api, self._node, self.created_ports, detach_and_delete_ports(self._connection, self._node,
self.attached_ports) self.created_ports, self.attached_ports)
def _get_port(self, nic): def _get_port(self, nic):
"""Validate and get the NIC information for a port. """Validate and get the NIC information for a port.
@ -100,7 +101,7 @@ class NICs(object):
'Unexpected fields for a port: %s' % ', '.join(unexpected)) 'Unexpected fields for a port: %s' % ', '.join(unexpected))
try: try:
port = self._api.connection.network.find_port( port = self._connection.network.find_port(
nic['port'], ignore_missing=False) nic['port'], ignore_missing=False)
except Exception as exc: except Exception as exc:
raise exceptions.InvalidNIC( raise exceptions.InvalidNIC(
@ -122,7 +123,7 @@ class NICs(object):
'Unexpected fields for a network: %s' % ', '.join(unexpected)) 'Unexpected fields for a network: %s' % ', '.join(unexpected))
try: try:
network = self._api.connection.network.find_network( network = self._connection.network.find_network(
nic['network'], ignore_missing=False) nic['network'], ignore_missing=False)
except Exception as exc: except Exception as exc:
raise exceptions.InvalidNIC( raise exceptions.InvalidNIC(
@ -136,33 +137,32 @@ class NICs(object):
return port_args return port_args
def detach_and_delete_ports(api, node, created_ports, attached_ports): def detach_and_delete_ports(connection, node, created_ports, attached_ports):
"""Detach attached port and delete previously created ones. """Detach attached port and delete previously created ones.
:param api: `Api` instance. :param connection: `openstacksdk.Connection` instance.
:param node: `Node` object to detach ports from. :param node: `Node` object to detach ports from.
:param created_ports: List of IDs of previously created ports. :param created_ports: List of IDs of previously created ports.
:param attached_ports: List of IDs of previously attached_ports. :param attached_ports: List of IDs of previously attached_ports.
""" """
for port_id in set(attached_ports + created_ports): for port_id in set(attached_ports + created_ports):
LOG.debug('Detaching port %(port)s from node %(node)s', LOG.debug('Detaching port %(port)s from node %(node)s',
{'port': port_id, 'node': node.uuid}) {'port': port_id, 'node': _utils.log_res(node)})
try: try:
api.detach_port_from_node(node, port_id) connection.baremetal.detach_vif_from_node(node, port_id)
except Exception as exc: except Exception as exc:
LOG.debug('Failed to remove VIF %(vif)s from node %(node)s, ' LOG.debug('Failed to remove VIF %(vif)s from node %(node)s, '
'assuming already removed: %(exc)s', 'assuming already removed: %(exc)s',
{'vif': port_id, 'node': _utils.log_node(node), {'vif': port_id, 'node': _utils.log_res(node),
'exc': exc}) 'exc': exc})
for port_id in created_ports: for port_id in created_ports:
LOG.debug('Deleting port %s', port_id) LOG.debug('Deleting port %s', port_id)
try: try:
api.connection.network.delete_port(port_id, connection.network.delete_port(port_id, ignore_missing=False)
ignore_missing=False)
except Exception as exc: except Exception as exc:
LOG.warning('Failed to delete neutron port %(port)s: %(exc)s', LOG.warning('Failed to delete neutron port %(port)s: %(exc)s',
{'port': port_id, 'exc': exc}) {'port': port_id, 'exc': exc})
else: else:
LOG.info('Deleted port %(port)s for node %(node)s', LOG.info('Deleted port %(port)s for node %(node)s',
{'port': port_id, 'node': _utils.log_node(node)}) {'port': port_id, 'node': _utils.log_res(node)})

View File

@ -1,182 +0,0 @@
# Copyright 2015-2018 Red Hat, 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.
import contextlib
import logging
from ironicclient import client as ir_client
import six
from metalsmith import _utils
LOG = logging.getLogger(__name__)
HOSTNAME_FIELD = 'metalsmith_hostname'
class _Remove(object):
"""Indicator that a field should be removed."""
__slots__ = ()
def __repr__(self):
"""Allow nicer logging."""
return '<REMOVE>'
REMOVE = _Remove()
class DictWithAttrs(dict):
__slots__ = ()
def __getattr__(self, attr):
try:
return self[attr]
except KeyError:
super(DictWithAttrs, self).__getattr__(attr)
class API(object):
"""Various OpenStack API's."""
IRONIC_VERSION = '1'
# TODO(dtantsur): use openstacksdk and stop hardcoding this here.
# 1.46 (Rocky) adds conductor_group.
IRONIC_MICRO_VERSION = '1.46'
_node_list = None
def __init__(self, session, connection):
self.ironic = ir_client.get_client(
self.IRONIC_VERSION, session=session,
os_ironic_api_version=self.IRONIC_MICRO_VERSION)
self.connection = connection
def _nodes_for_lookup(self):
return self.list_nodes(maintenance=None,
associated=None,
provision_state=None,
fields=['uuid', 'name', 'instance_info'])
def attach_port_to_node(self, node, port_id):
self.ironic.node.vif_attach(_node_id(node), port_id)
@contextlib.contextmanager
def cache_node_list_for_lookup(self):
if self._node_list is None:
self._node_list = self._nodes_for_lookup()
yield self._node_list
self._node_list = None
def detach_port_from_node(self, node, port_id):
self.ironic.node.vif_detach(_node_id(node), port_id)
def find_node_by_hostname(self, hostname):
nodes = self._node_list or self._nodes_for_lookup()
existing = [n for n in nodes
if n.instance_info.get(HOSTNAME_FIELD) == hostname]
if len(existing) > 1:
raise RuntimeError("More than one node found with hostname "
"%(host)s: %(nodes)s" %
{'host': hostname,
'nodes': ', '.join(_utils.log_node(n)
for n in existing)})
elif not existing:
return None
else:
# Fetch the complete node record
return self.get_node(existing[0].uuid, accept_hostname=False)
def get_node(self, node, refresh=False, accept_hostname=False):
if isinstance(node, six.string_types):
if accept_hostname and _utils.is_hostname_safe(node):
by_hostname = self.find_node_by_hostname(node)
if by_hostname is not None:
return by_hostname
return self.ironic.node.get(node)
elif hasattr(node, 'node'):
# Instance object
node = node.node
else:
node = node
if refresh:
return self.ironic.node.get(node.uuid)
else:
return node
def list_node_attached_ports(self, node):
return self.ironic.node.vif_list(_node_id(node))
def list_node_ports(self, node):
return self.ironic.node.list_ports(_node_id(node), limit=0)
def list_nodes(self, maintenance=False, associated=False,
provision_state='available', **filters):
if 'fields' not in filters:
filters['detail'] = True
return self.ironic.node.list(limit=0, maintenance=maintenance,
associated=associated,
provision_state=provision_state,
**filters)
def node_action(self, node, action, **kwargs):
self.ironic.node.set_provision_state(_node_id(node), action, **kwargs)
def release_node(self, node):
return self.update_node(_node_id(node), instance_uuid=REMOVE)
def reserve_node(self, node, instance_uuid):
return self.update_node(_node_id(node), instance_uuid=instance_uuid)
def update_node(self, node, *args, **attrs):
if args:
attrs.update(args[0])
patches = _convert_patches(attrs)
return self.ironic.node.update(_node_id(node), patches)
def validate_node(self, node, validate_deploy=False):
ifaces = ['power', 'management']
if validate_deploy:
ifaces += ['deploy']
validation = self.ironic.node.validate(_node_id(node))
for iface in ifaces:
result = getattr(validation, iface)
if not result['result']:
raise RuntimeError('%s: %s' % (iface, result['reason']))
def _node_id(node):
if isinstance(node, six.string_types):
return node
else:
return node.uuid
def _convert_patches(attrs):
patches = []
for key, value in attrs.items():
if not key.startswith('/'):
key = '/' + key
if value is REMOVE:
patches.append({'op': 'remove', 'path': key})
else:
patches.append({'op': 'add', 'path': key, 'value': value})
return patches

View File

@ -16,7 +16,6 @@
import logging import logging
import random import random
import sys import sys
import time
import warnings import warnings
from openstack import connection from openstack import connection
@ -25,7 +24,6 @@ import six
from metalsmith import _config from metalsmith import _config
from metalsmith import _instance from metalsmith import _instance
from metalsmith import _nics from metalsmith import _nics
from metalsmith import _os_api
from metalsmith import _scheduler from metalsmith import _scheduler
from metalsmith import _utils from metalsmith import _utils
from metalsmith import exceptions from metalsmith import exceptions
@ -38,7 +36,7 @@ _CREATED_PORTS = 'metalsmith_created_ports'
_ATTACHED_PORTS = 'metalsmith_attached_ports' _ATTACHED_PORTS = 'metalsmith_attached_ports'
class Provisioner(object): class Provisioner(_utils.GetNodeMixin):
"""API to deploy/undeploy nodes with OpenStack. """API to deploy/undeploy nodes with OpenStack.
:param session: `Session` object (from ``keystoneauth``) to use when :param session: `Session` object (from ``keystoneauth``) to use when
@ -63,11 +61,7 @@ class Provisioner(object):
'but not both') 'but not both')
else: else:
self.connection = connection.Connection(config=cloud_region) self.connection = connection.Connection(config=cloud_region)
# NOTE(dtantsur): Connection.baremetal is a keystoneauth Adapter
# for baremetal API.
session = self.connection.baremetal
self._api = _os_api.API(session, self.connection)
self._dry_run = dry_run self._dry_run = dry_run
def reserve_node(self, resource_class=None, conductor_group=None, def reserve_node(self, resource_class=None, conductor_group=None,
@ -103,13 +97,15 @@ class Provisioner(object):
DeprecationWarning) DeprecationWarning)
if candidates: if candidates:
nodes = [self._api.get_node(node) for node in candidates] nodes = [self._get_node(node) for node in candidates]
filters = [ filters = [
_scheduler.NodeTypeFilter(resource_class, conductor_group), _scheduler.NodeTypeFilter(resource_class, conductor_group),
] ]
else: else:
nodes = self._api.list_nodes(resource_class=resource_class, nodes = list(self.connection.baremetal.nodes(
conductor_group=conductor_group) resource_class=resource_class,
conductor_group=conductor_group,
details=True))
if not nodes: if not nodes:
raise exceptions.NodesNotFound(resource_class, conductor_group) raise exceptions.NodesNotFound(resource_class, conductor_group)
# Ensure parallel executions don't try nodes in the same sequence # Ensure parallel executions don't try nodes in the same sequence
@ -124,18 +120,16 @@ class Provisioner(object):
if predicate is not None: if predicate is not None:
filters.append(_scheduler.CustomPredicateFilter(predicate)) filters.append(_scheduler.CustomPredicateFilter(predicate))
reserver = _scheduler.IronicReserver(self._api) instance_info = {}
if capabilities:
instance_info['capabilities'] = capabilities
if traits:
instance_info['traits'] = traits
reserver = _scheduler.IronicReserver(self.connection,
instance_info)
node = _scheduler.schedule_node(nodes, filters, reserver, node = _scheduler.schedule_node(nodes, filters, reserver,
dry_run=self._dry_run) dry_run=self._dry_run)
update = {}
if capabilities:
update['/instance_info/capabilities'] = capabilities
if traits:
update['/instance_info/traits'] = traits
if update:
node = self._api.update_node(node, update)
LOG.debug('Reserved node: %s', node) LOG.debug('Reserved node: %s', node)
return node return node
@ -148,28 +142,29 @@ class Provisioner(object):
reserved by us or are in maintenance mode. reserved by us or are in maintenance mode.
""" """
try: try:
node = self._api.get_node(node) node = self._get_node(node)
except Exception as exc: except Exception as exc:
raise exceptions.InvalidNode('Cannot find node %(node)s: %(exc)s' % raise exceptions.InvalidNode('Cannot find node %(node)s: %(exc)s' %
{'node': node, 'exc': exc}) {'node': node, 'exc': exc})
if not node.instance_uuid: if not node.instance_id:
if not self._dry_run: if not self._dry_run:
LOG.debug('Node %s not reserved yet, reserving', LOG.debug('Node %s not reserved yet, reserving',
_utils.log_node(node)) _utils.log_res(node))
self._api.reserve_node(node, instance_uuid=node.uuid) self.connection.baremetal.update_node(
elif node.instance_uuid != node.uuid: node, instance_id=node.id)
elif node.instance_id != node.id:
raise exceptions.InvalidNode('Node %(node)s already reserved ' raise exceptions.InvalidNode('Node %(node)s already reserved '
'by instance %(inst)s outside of ' 'by instance %(inst)s outside of '
'metalsmith, cannot deploy on it' % 'metalsmith, cannot deploy on it' %
{'node': _utils.log_node(node), {'node': _utils.log_res(node),
'inst': node.instance_uuid}) 'inst': node.instance_id})
if node.maintenance: if node.is_maintenance:
raise exceptions.InvalidNode('Refusing to deploy on node %(node)s ' raise exceptions.InvalidNode('Refusing to deploy on node %(node)s '
'which is in maintenance mode due to ' 'which is in maintenance mode due to '
'%(reason)s' % '%(reason)s' %
{'node': _utils.log_node(node), {'node': _utils.log_res(node),
'reason': node.maintenance_reason}) 'reason': node.maintenance_reason})
return node return node
@ -187,17 +182,17 @@ class Provisioner(object):
if node.name and _utils.is_hostname_safe(node.name): if node.name and _utils.is_hostname_safe(node.name):
return node.name return node.name
else: else:
return node.uuid return node.id
if not _utils.is_hostname_safe(hostname): if not _utils.is_hostname_safe(hostname):
raise ValueError("%s cannot be used as a hostname" % hostname) raise ValueError("%s cannot be used as a hostname" % hostname)
existing = self._api.find_node_by_hostname(hostname) existing = self._find_node_by_hostname(hostname)
if existing is not None and existing.uuid != node.uuid: if existing is not None and existing.id != node.id:
raise ValueError("The following node already uses hostname " raise ValueError("The following node already uses hostname "
"%(host)s: %(node)s" % "%(host)s: %(node)s" %
{'host': hostname, {'host': hostname,
'node': _utils.log_node(existing)}) 'node': _utils.log_res(existing)})
return hostname return hostname
@ -256,7 +251,7 @@ class Provisioner(object):
image = sources.GlanceImage(image) image = sources.GlanceImage(image)
node = self._check_node_for_deploy(node) node = self._check_node_for_deploy(node)
nics = _nics.NICs(self._api, node, nics) nics = _nics.NICs(self.connection, node, nics)
try: try:
hostname = self._check_hostname(node, hostname) hostname = self._check_hostname(node, hostname)
@ -271,62 +266,71 @@ class Provisioner(object):
if self._dry_run: if self._dry_run:
LOG.warning('Dry run, not provisioning node %s', LOG.warning('Dry run, not provisioning node %s',
_utils.log_node(node)) _utils.log_res(node))
return node return node
nics.create_and_attach_ports() nics.create_and_attach_ports()
capabilities['boot_option'] = 'netboot' if netboot else 'local' capabilities['boot_option'] = 'netboot' if netboot else 'local'
updates = {'/instance_info/root_gb': root_size_gb, instance_info = node.instance_info.copy()
'/instance_info/capabilities': capabilities, instance_info['root_gb'] = root_size_gb
'/extra/%s' % _CREATED_PORTS: nics.created_ports, instance_info['capabilities'] = capabilities
'/extra/%s' % _ATTACHED_PORTS: nics.attached_ports, instance_info[self.HOSTNAME_FIELD] = hostname
'/instance_info/%s' % _os_api.HOSTNAME_FIELD: hostname} extra = node.extra.copy()
updates.update(image._node_updates(self.connection)) extra[_CREATED_PORTS] = nics.created_ports
extra[_ATTACHED_PORTS] = nics.attached_ports
instance_info.update(image._node_updates(self.connection))
if traits is not None: if traits is not None:
updates['/instance_info/traits'] = traits instance_info['traits'] = traits
if swap_size_mb is not None: if swap_size_mb is not None:
updates['/instance_info/swap_mb'] = swap_size_mb instance_info['swap_mb'] = swap_size_mb
LOG.debug('Updating node %(node)s with %(updates)s', LOG.debug('Updating node %(node)s with instance info %(iinfo)s '
{'node': _utils.log_node(node), 'updates': updates}) 'and extras %(extra)s', {'node': _utils.log_res(node),
node = self._api.update_node(node, updates) 'iinfo': instance_info,
self._api.validate_node(node, validate_deploy=True) 'extra': extra})
node = self.connection.baremetal.update_node(
node, instance_info=instance_info, extra=extra)
self.connection.baremetal.validate_node(node)
LOG.debug('Generating a configdrive for node %s', LOG.debug('Generating a configdrive for node %s',
_utils.log_node(node)) _utils.log_res(node))
with config.build_configdrive_directory(node, hostname) as cd: cd = config.build_configdrive(node, hostname)
self._api.node_action(node, 'active', # TODO(dtantsur): move this to openstacksdk?
configdrive=cd) if not isinstance(cd, six.string_types):
cd = cd.decode('utf-8')
LOG.debug('Starting provisioning of node %s', _utils.log_res(node))
self.connection.baremetal.set_node_provision_state(
node, 'active', config_drive=cd)
except Exception: except Exception:
exc_info = sys.exc_info() exc_info = sys.exc_info()
try: try:
LOG.error('Deploy attempt failed on node %s, cleaning up', LOG.error('Deploy attempt failed on node %s, cleaning up',
_utils.log_node(node)) _utils.log_res(node))
self._clean_up(node, nics=nics) self._clean_up(node, nics=nics)
except Exception: except Exception:
LOG.exception('Clean up failed') LOG.exception('Clean up failed')
six.reraise(*exc_info) six.reraise(*exc_info)
LOG.info('Provisioning started on node %s', _utils.log_node(node)) LOG.info('Provisioning started on node %s', _utils.log_res(node))
if wait is not None: if wait is not None:
LOG.debug('Waiting for node %(node)s to reach state active ' LOG.debug('Waiting for node %(node)s to reach state active '
'with timeout %(timeout)s', 'with timeout %(timeout)s',
{'node': _utils.log_node(node), 'timeout': wait}) {'node': _utils.log_res(node), 'timeout': wait})
instance = self.wait_for_provisioning([node], timeout=wait)[0] instance = self.wait_for_provisioning([node], timeout=wait)[0]
LOG.info('Deploy succeeded on node %s', _utils.log_node(node)) LOG.info('Deploy succeeded on node %s', _utils.log_res(node))
else: else:
# Update the node to return it's latest state # Update the node to return it's latest state
node = self._api.get_node(node, refresh=True) node = self._get_node(node, refresh=True)
instance = _instance.Instance(self._api, node) instance = _instance.Instance(self.connection, node)
return instance return instance
def wait_for_provisioning(self, nodes, timeout=None, delay=15): def wait_for_provisioning(self, nodes, timeout=None, delay=None):
"""Wait for nodes to be provisioned. """Wait for nodes to be provisioned.
Loops until all nodes finish provisioning. Loops until all nodes finish provisioning.
@ -336,96 +340,46 @@ class Provisioner(object):
:param timeout: How much time (in seconds) to wait for all nodes :param timeout: How much time (in seconds) to wait for all nodes
to finish provisioning. If ``None`` (the default), wait forever to finish provisioning. If ``None`` (the default), wait forever
(more precisely, until the operation times out on server side). (more precisely, until the operation times out on server side).
:param delay: Delay (in seconds) between two provision state checks. :param delay: DEPRECATED, do not use.
:return: List of updated :py:class:`metalsmith.Instance` objects if :return: List of updated :py:class:`metalsmith.Instance` objects if
all succeeded. all succeeded.
:raises: :py:class:`metalsmith.exceptions.DeploymentFailure` :raises: :py:class:`metalsmith.exceptions.DeploymentFailure`
if the deployment failed or timed out for any nodes. if the deployment failed or timed out for any nodes.
""" """
nodes = self._wait_for_state(nodes, 'active', if delay is not None:
timeout=timeout, delay=delay) warnings.warn("The delay argument to wait_for_provisioning is "
return [_instance.Instance(self._api, node) for node in nodes] "deprecated and has not effect", DeprecationWarning)
nodes = [self._get_node(n, accept_hostname=True) for n in nodes]
def _wait_for_state(self, nodes, state, timeout, delay=15): nodes = self.connection.baremetal.wait_for_nodes_provision_state(
if timeout is not None and timeout <= 0: nodes, 'active', timeout=timeout)
raise ValueError("The timeout argument must be a positive int") return [_instance.Instance(self.connection, node) for node in nodes]
if delay < 0:
raise ValueError("The delay argument must be a non-negative int")
failed_nodes = []
finished_nodes = []
deadline = time.time() + timeout if timeout is not None else None
while timeout is None or time.time() < deadline:
remaining_nodes = []
for node in nodes:
node = self._api.get_node(node, refresh=True,
accept_hostname=True)
if node.provision_state == state:
LOG.debug('Node %(node)s reached state %(state)s',
{'node': _utils.log_node(node), 'state': state})
finished_nodes.append(node)
elif (node.provision_state == 'error' or
node.provision_state.endswith(' failed')):
LOG.error('Node %(node)s failed deployment: %(error)s',
{'node': _utils.log_node(node),
'error': node.last_error})
failed_nodes.append(node)
else:
remaining_nodes.append(node)
if remaining_nodes:
nodes = remaining_nodes
else:
nodes = []
break
LOG.debug('Still waiting for the following nodes to reach state '
'%(state)s: %(nodes)s',
{'state': state,
'nodes': ', '.join(_utils.log_node(n) for n in nodes)})
time.sleep(delay)
messages = []
if failed_nodes:
messages.append('the following nodes failed deployment: %s' %
', '.join('%s (%s)' % (_utils.log_node(node),
node.last_error)
for node in failed_nodes))
if nodes:
messages.append('deployment timed out for nodes %s' %
', '.join(_utils.log_node(node) for node in nodes))
if messages:
raise exceptions.DeploymentFailure(
'Deployment failed: %s' % '; '.join(messages),
failed_nodes + nodes)
else:
LOG.debug('All nodes reached state %s', state)
return finished_nodes
def _clean_up(self, node, nics=None): def _clean_up(self, node, nics=None):
if nics is None: if nics is None:
created_ports = node.extra.get(_CREATED_PORTS, []) created_ports = node.extra.get(_CREATED_PORTS, [])
attached_ports = node.extra.get(_ATTACHED_PORTS, []) attached_ports = node.extra.get(_ATTACHED_PORTS, [])
_nics.detach_and_delete_ports(self._api, node, created_ports, _nics.detach_and_delete_ports(self.connection, node,
attached_ports) created_ports, attached_ports)
else: else:
nics.detach_and_delete_ports() nics.detach_and_delete_ports()
update = {'/extra/%s' % item: _os_api.REMOVE extra = node.extra.copy()
for item in (_CREATED_PORTS, _ATTACHED_PORTS)} for item in (_CREATED_PORTS, _ATTACHED_PORTS):
update['/instance_info/%s' % _os_api.HOSTNAME_FIELD] = _os_api.REMOVE extra.pop(item, None)
LOG.debug('Updating node %(node)s with %(updates)s', instance_info = node.instance_info.copy()
{'node': _utils.log_node(node), 'updates': update}) instance_info.pop(self.HOSTNAME_FIELD, None)
LOG.debug('Updating node %(node)s with instance info %(iinfo)s '
'and extras %(extra)s and releasing the lock',
{'node': _utils.log_res(node),
'iinfo': instance_info,
'extra': extra})
try: try:
self._api.update_node(node, update) self.connection.baremetal.update_node(
node, instance_info=instance_info, extra=extra,
instance_id=None)
except Exception as exc: except Exception as exc:
LOG.debug('Failed to clear node %(node)s extra: %(exc)s', LOG.debug('Failed to clear node %(node)s extra: %(exc)s',
{'node': _utils.log_node(node), 'exc': exc}) {'node': _utils.log_res(node), 'exc': exc})
LOG.debug('Releasing lock on node %s', _utils.log_node(node))
self._api.release_node(node)
def unprovision_node(self, node, wait=None): def unprovision_node(self, node, wait=None):
"""Unprovision a previously provisioned node. """Unprovision a previously provisioned node.
@ -436,21 +390,23 @@ class Provisioner(object):
None to return immediately. None to return immediately.
:return: the latest `Node` object. :return: the latest `Node` object.
""" """
node = self._api.get_node(node, accept_hostname=True) node = self._get_node(node, accept_hostname=True)
if self._dry_run: if self._dry_run:
LOG.warning("Dry run, not unprovisioning") LOG.warning("Dry run, not unprovisioning")
return return
self._clean_up(node) self._clean_up(node)
self._api.node_action(node, 'deleted') node = self.connection.baremetal.set_node_provision_state(
node, 'deleted', wait=False)
LOG.info('Deleting started for node %s', _utils.log_node(node)) LOG.info('Deleting started for node %s', _utils.log_res(node))
if wait is not None: if wait is not None:
self._wait_for_state([node], 'available', timeout=wait) node = self.connection.baremetal.wait_for_nodes_provision_state(
LOG.info('Node %s undeployed successfully', _utils.log_node(node)) [node], 'available', timeout=wait)[0]
LOG.info('Node %s undeployed successfully', _utils.log_res(node))
return self._api.get_node(node, refresh=True) return node
def show_instance(self, instance_id): def show_instance(self, instance_id):
"""Show information about instance. """Show information about instance.
@ -470,11 +426,11 @@ class Provisioner(object):
:return: list of :py:class:`metalsmith.Instance` objects in the same :return: list of :py:class:`metalsmith.Instance` objects in the same
order as ``instances``. order as ``instances``.
""" """
with self._api.cache_node_list_for_lookup(): with self._cache_node_list_for_lookup():
return [ return [
_instance.Instance( _instance.Instance(
self._api, self.connection,
self._api.get_node(inst, accept_hostname=True)) self._get_node(inst, accept_hostname=True))
for inst in instances for inst in instances
] ]
@ -483,8 +439,9 @@ class Provisioner(object):
:return: list of :py:class:`metalsmith.Instance` objects. :return: list of :py:class:`metalsmith.Instance` objects.
""" """
nodes = self._api.list_nodes(provision_state=None, associated=True) nodes = self.connection.baremetal.nodes(associated=True, details=True)
instances = [i for i in instances = [i for i in
(_instance.Instance(self._api, node) for node in nodes) (_instance.Instance(self.connection, node)
for node in nodes)
if i._is_deployed_by_metalsmith] if i._is_deployed_by_metalsmith]
return instances return instances

View File

@ -17,6 +17,7 @@ import abc
import collections import collections
import logging import logging
from openstack import exceptions as sdk_exc
import six import six
from metalsmith import _utils from metalsmith import _utils
@ -100,13 +101,13 @@ def schedule_node(nodes, filters, reserver, dry_run=False):
for node in nodes: for node in nodes:
try: try:
result = reserver(node) result = reserver(node)
except Exception as exc: except sdk_exc.SDKException as exc:
LOG.debug('Node %(node)s was not reserved (%(exc)s), moving on ' LOG.debug('Node %(node)s was not reserved (%(exc)s), moving on '
'to the next one', 'to the next one',
{'node': _utils.log_node(node), 'exc': exc}) {'node': _utils.log_res(node), 'exc': exc})
else: else:
LOG.info('Node %s reserved for deployment', LOG.info('Node %s reserved for deployment',
_utils.log_node(result)) _utils.log_res(result))
return result return result
LOG.debug('No nodes could be reserved') LOG.debug('No nodes could be reserved')
@ -149,25 +150,25 @@ class CapabilitiesFilter(Filter):
caps = _utils.get_capabilities(node) caps = _utils.get_capabilities(node)
except Exception: except Exception:
LOG.exception('Malformed capabilities on node %(node)s: %(caps)s', LOG.exception('Malformed capabilities on node %(node)s: %(caps)s',
{'node': _utils.log_node(node), {'node': _utils.log_res(node),
'caps': node.properties.get('capabilities')}) 'caps': node.properties.get('capabilities')})
return False return False
LOG.debug('Capabilities for node %(node)s: %(caps)s', LOG.debug('Capabilities for node %(node)s: %(caps)s',
{'node': _utils.log_node(node), 'caps': caps}) {'node': _utils.log_res(node), 'caps': caps})
for key, value in self._capabilities.items(): for key, value in self._capabilities.items():
try: try:
node_value = caps[key] node_value = caps[key]
except KeyError: except KeyError:
LOG.debug('Node %(node)s does not have capability %(cap)s', LOG.debug('Node %(node)s does not have capability %(cap)s',
{'node': _utils.log_node(node), 'cap': key}) {'node': _utils.log_res(node), 'cap': key})
return False return False
else: else:
self._counter["%s=%s" % (key, node_value)] += 1 self._counter["%s=%s" % (key, node_value)] += 1
if value != node_value: if value != node_value:
LOG.debug('Node %(node)s has capability %(cap)s of ' LOG.debug('Node %(node)s has capability %(cap)s of '
'value "%(node_val)s" instead of "%(expected)s"', 'value "%(node_val)s" instead of "%(expected)s"',
{'node': _utils.log_node(node), 'cap': key, {'node': _utils.log_res(node), 'cap': key,
'node_val': node_value, 'expected': value}) 'node_val': node_value, 'expected': value})
return False return False
@ -197,14 +198,14 @@ class TraitsFilter(Filter):
traits = node.traits or [] traits = node.traits or []
LOG.debug('Traits for node %(node)s: %(traits)s', LOG.debug('Traits for node %(node)s: %(traits)s',
{'node': _utils.log_node(node), 'traits': traits}) {'node': _utils.log_res(node), 'traits': traits})
for trait in traits: for trait in traits:
self._counter[trait] += 1 self._counter[trait] += 1
missing = set(self._traits) - set(traits) missing = set(self._traits) - set(traits)
if missing: if missing:
LOG.debug('Node %(node)s does not have traits %(missing)s', LOG.debug('Node %(node)s does not have traits %(missing)s',
{'node': _utils.log_node(node), 'missing': missing}) {'node': _utils.log_res(node), 'missing': missing})
return False return False
return True return True
@ -239,24 +240,28 @@ class CustomPredicateFilter(Filter):
class IronicReserver(Reserver): class IronicReserver(Reserver):
def __init__(self, api): def __init__(self, connection, instance_info=None):
self._api = api self._connection = connection
self._failed_nodes = [] self._failed_nodes = []
self._iinfo = instance_info or {}
def validate(self, node): def validate(self, node):
try: try:
self._api.validate_node(node) self._connection.baremetal.validate_node(
except RuntimeError as exc: node, required=('power', 'management'))
except sdk_exc.SDKException as exc:
message = ('Node %(node)s failed validation: %(err)s' % message = ('Node %(node)s failed validation: %(err)s' %
{'node': _utils.log_node(node), 'err': exc}) {'node': _utils.log_res(node), 'err': exc})
LOG.warning(message) LOG.warning(message)
raise exceptions.ValidationFailed(message) raise exceptions.ValidationFailed(message)
def __call__(self, node): def __call__(self, node):
try: try:
self.validate(node) self.validate(node)
return self._api.reserve_node(node, instance_uuid=node.uuid) iinfo = dict(node.instance_info or {}, **self._iinfo)
except Exception: return self._connection.baremetal.update_node(
node, instance_id=node.id, instance_info=iinfo)
except sdk_exc.SDKException:
self._failed_nodes.append(node) self._failed_nodes.append(node)
raise raise

View File

@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import contextlib
import re import re
import six import six
@ -20,13 +21,6 @@ import six
from metalsmith import exceptions from metalsmith import exceptions
def log_node(node):
if node.name:
return '%s (UUID %s)' % (node.name, node.uuid)
else:
return node.uuid
def log_res(res): def log_res(res):
if getattr(res, 'name', None): if getattr(res, 'name', None):
return '%s (UUID %s)' % (res.name, res.id) return '%s (UUID %s)' % (res.name, res.id)
@ -56,12 +50,12 @@ def get_root_disk(root_size_gb, node):
except KeyError: except KeyError:
raise exceptions.UnknownRootDiskSize( raise exceptions.UnknownRootDiskSize(
'No local_gb for node %s and no root partition size ' 'No local_gb for node %s and no root partition size '
'specified' % log_node(node)) 'specified' % log_res(node))
except (TypeError, ValueError, AssertionError): except (TypeError, ValueError, AssertionError):
raise exceptions.UnknownRootDiskSize( raise exceptions.UnknownRootDiskSize(
'The local_gb for node %(node)s is invalid: ' 'The local_gb for node %(node)s is invalid: '
'expected positive integer, got %(value)s' % 'expected positive integer, got %(value)s' %
{'node': log_node(node), {'node': log_res(node),
'value': node.properties['local_gb']}) 'value': node.properties['local_gb']})
# allow for partitioning and config drive # allow for partitioning and config drive
@ -104,3 +98,65 @@ def parse_checksums(checksums):
result[fname.strip().lstrip('*')] = checksum.strip() result[fname.strip().lstrip('*')] = checksum.strip()
return result return result
class GetNodeMixin(object):
"""A helper mixin for getting nodes with hostnames."""
HOSTNAME_FIELD = 'metalsmith_hostname'
_node_list = None
def _available_nodes(self):
return self.connection.baremetal.nodes(details=True,
associated=False,
provision_state='available',
is_maintenance=False)
def _nodes_for_lookup(self):
return self.connection.baremetal.nodes(
fields=['uuid', 'name', 'instance_info'])
def _find_node_by_hostname(self, hostname):
"""A helper to find a node by metalsmith hostname."""
nodes = self._node_list or self._nodes_for_lookup()
existing = [n for n in nodes
if n.instance_info.get(self.HOSTNAME_FIELD) == hostname]
if len(existing) > 1:
raise RuntimeError("More than one node found with hostname "
"%(host)s: %(nodes)s" %
{'host': hostname,
'nodes': ', '.join(log_res(n)
for n in existing)})
elif not existing:
return None
else:
# Fetch the complete node information before returning
return self.connection.baremetal.get_node(existing[0].id)
def _get_node(self, node, refresh=False, accept_hostname=False):
"""A helper to find and return a node."""
if isinstance(node, six.string_types):
if accept_hostname and is_hostname_safe(node):
by_hostname = self._find_node_by_hostname(node)
if by_hostname is not None:
return by_hostname
return self.connection.baremetal.get_node(node)
elif hasattr(node, 'node'):
# Instance object
node = node.node
else:
node = node
if refresh:
return self.connection.baremetal.get_node(node)
else:
return node
@contextlib.contextmanager
def _cache_node_list_for_lookup(self):
if self._node_list is None:
self._node_list = list(self._nodes_for_lookup())
yield self._node_list
self._node_list = None

View File

@ -69,12 +69,12 @@ class GlanceImage(_Source):
LOG.debug('Image: %s', self._image_obj) LOG.debug('Image: %s', self._image_obj)
updates = { updates = {
'/instance_info/image_source': self._image_obj.id 'image_source': self._image_obj.id
} }
for prop in ('kernel', 'ramdisk'): for prop in ('kernel', 'ramdisk'):
value = getattr(self._image_obj, '%s_id' % prop, None) value = getattr(self._image_obj, '%s_id' % prop, None)
if value: if value:
updates['/instance_info/%s' % prop] = value updates[prop] = value
return updates return updates
@ -144,8 +144,8 @@ class HttpWholeDiskImage(_Source):
LOG.debug('Image: %(image)s, checksum %(checksum)s', LOG.debug('Image: %(image)s, checksum %(checksum)s',
{'image': self.url, 'checksum': self.checksum}) {'image': self.url, 'checksum': self.checksum})
return { return {
'/instance_info/image_source': self.url, 'image_source': self.url,
'/instance_info/image_checksum': self.checksum, 'image_checksum': self.checksum,
} }
@ -172,8 +172,8 @@ class HttpPartitionImage(HttpWholeDiskImage):
def _node_updates(self, connection): def _node_updates(self, connection):
updates = super(HttpPartitionImage, self)._node_updates(connection) updates = super(HttpPartitionImage, self)._node_updates(connection)
updates['/instance_info/kernel'] = self.kernel_url updates['kernel'] = self.kernel_url
updates['/instance_info/ramdisk'] = self.ramdisk_url updates['ramdisk'] = self.ramdisk_url
return updates return updates
@ -203,8 +203,8 @@ class FileWholeDiskImage(_Source):
LOG.debug('Image: %(image)s, checksum %(checksum)s', LOG.debug('Image: %(image)s, checksum %(checksum)s',
{'image': self.location, 'checksum': self.checksum}) {'image': self.location, 'checksum': self.checksum})
return { return {
'/instance_info/image_source': self.location, 'image_source': self.location,
'/instance_info/image_checksum': self.checksum, 'image_checksum': self.checksum,
} }
@ -239,6 +239,6 @@ class FilePartitionImage(FileWholeDiskImage):
def _node_updates(self, connection): def _node_updates(self, connection):
updates = super(FilePartitionImage, self)._node_updates(connection) updates = super(FilePartitionImage, self)._node_updates(connection)
updates['/instance_info/kernel'] = self.kernel_location updates['kernel'] = self.kernel_location
updates['/instance_info/ramdisk'] = self.ramdisk_location updates['ramdisk'] = self.ramdisk_location
return updates return updates

View File

@ -75,7 +75,7 @@ class TestDeploy(testtools.TestCase):
instance = mock_pr.return_value.provision_node.return_value instance = mock_pr.return_value.provision_node.return_value
instance.create_autospec(_instance.Instance) instance.create_autospec(_instance.Instance)
instance.node.name = None instance.node.name = None
instance.node.uuid = '123' instance.node.id = '123'
instance.state = 'active' instance.state = 'active'
instance.is_deployed = True instance.is_deployed = True
instance.ip_addresses.return_value = {'private': ['1.2.3.4']} instance.ip_addresses.return_value = {'private': ['1.2.3.4']}
@ -127,7 +127,7 @@ class TestDeploy(testtools.TestCase):
instance.is_deployed = True instance.is_deployed = True
instance.ip_addresses.return_value = {} instance.ip_addresses.return_value = {}
instance.node.name = None instance.node.name = None
instance.node.uuid = '123' instance.node.id = '123'
instance.state = 'active' instance.state = 'active'
args = ['deploy', '--network', 'mynet', '--image', 'myimg', args = ['deploy', '--network', 'mynet', '--image', 'myimg',
@ -142,7 +142,7 @@ class TestDeploy(testtools.TestCase):
instance.create_autospec(_instance.Instance) instance.create_autospec(_instance.Instance)
instance.is_deployed = False instance.is_deployed = False
instance.node.name = None instance.node.name = None
instance.node.uuid = '123' instance.node.id = '123'
instance.state = 'deploying' instance.state = 'deploying'
args = ['deploy', '--network', 'mynet', '--image', 'myimg', args = ['deploy', '--network', 'mynet', '--image', 'myimg',
@ -487,7 +487,7 @@ class TestUndeploy(testtools.TestCase):
def test_ok(self, mock_os_conf, mock_pr): def test_ok(self, mock_os_conf, mock_pr):
node = mock_pr.return_value.unprovision_node.return_value node = mock_pr.return_value.unprovision_node.return_value
node.uuid = '123' node.id = '123'
node.name = None node.name = None
node.provision_state = 'cleaning' node.provision_state = 'cleaning'
@ -506,7 +506,7 @@ class TestUndeploy(testtools.TestCase):
def test_custom_wait(self, mock_os_conf, mock_pr): def test_custom_wait(self, mock_os_conf, mock_pr):
node = mock_pr.return_value.unprovision_node.return_value node = mock_pr.return_value.unprovision_node.return_value
node.uuid = '123' node.id = '123'
node.name = None node.name = None
node.provision_state = 'available' node.provision_state = 'available'
@ -580,9 +580,9 @@ class TestShowWait(testtools.TestCase):
for hostname in ['hostname1', 'hostname2'] for hostname in ['hostname1', 'hostname2']
] ]
for inst in self.instances: for inst in self.instances:
inst.node.uuid = inst.uuid inst.node.id = inst.uuid
inst.node.name = 'name-%s' % inst.uuid inst.node.name = 'name-%s' % inst.uuid
inst.to_dict.return_value = {inst.node.uuid: inst.node.name} inst.to_dict.return_value = {inst.node.id: inst.node.name}
def test_show(self, mock_os_conf, mock_pr): def test_show(self, mock_os_conf, mock_pr):
mock_pr.return_value.show_instances.return_value = self.instances mock_pr.return_value.show_instances.return_value = self.instances

View File

@ -14,9 +14,9 @@
# limitations under the License. # limitations under the License.
import json import json
import os
import mock import mock
from openstack.baremetal import configdrive
import testtools import testtools
from metalsmith import _config from metalsmith import _config
@ -25,7 +25,7 @@ from metalsmith import _config
class TestInstanceConfig(testtools.TestCase): class TestInstanceConfig(testtools.TestCase):
def setUp(self): def setUp(self):
super(TestInstanceConfig, self).setUp() super(TestInstanceConfig, self).setUp()
self.node = mock.Mock(uuid='1234') self.node = mock.Mock(id='1234')
self.node.name = 'node name' self.node.name = 'node name'
def _check(self, config, expected_metadata, expected_userdata=None): def _check(self, config, expected_metadata, expected_userdata=None):
@ -39,24 +39,19 @@ class TestInstanceConfig(testtools.TestCase):
'meta': {}} 'meta': {}}
expected_m.update(expected_metadata) expected_m.update(expected_metadata)
with config.build_configdrive_directory(self.node, 'example.com') as d: with mock.patch.object(configdrive, 'build', autospec=True) as mb:
for version in ('2012-08-10', 'latest'): result = config.build_configdrive(self.node, "example.com")
with open(os.path.join(d, 'openstack', version, mb.assert_called_once_with(expected_m, mock.ANY)
'meta_data.json')) as fp: self.assertIs(result, mb.return_value)
metadata = json.load(fp) user_data = mb.call_args[0][1]
self.assertEqual(expected_m, metadata) if expected_userdata:
user_data = os.path.join(d, 'openstack', version, 'user_data') self.assertIsNotNone(user_data)
if expected_userdata is None: user_data = user_data.decode('utf-8')
self.assertFalse(os.path.exists(user_data)) header, user_data = user_data.split('\n', 1)
else: self.assertEqual('#cloud-config', header)
with open(user_data) as fp: user_data = json.loads(user_data)
lines = list(fp) self.assertEqual(expected_userdata, user_data)
self.assertEqual('#cloud-config\n', lines[0])
user_data = json.loads(''.join(lines[1:]))
self.assertEqual(expected_userdata, user_data)
self.assertFalse(os.path.exists(d))
def test_default(self): def test_default(self):
config = _config.InstanceConfig() config = _config.InstanceConfig()

View File

@ -23,21 +23,19 @@ class TestInstanceIPAddresses(test_provisioner.Base):
def setUp(self): def setUp(self):
super(TestInstanceIPAddresses, self).setUp() super(TestInstanceIPAddresses, self).setUp()
self.instance = _instance.Instance(self.api, self.node) self.instance = _instance.Instance(self.api, self.node)
self.api.list_node_attached_ports.return_value = [ self.api.baremetal.list_node_vifs.return_value = ['111', '222']
mock.Mock(spec=['id'], id=i) for i in ('111', '222')
]
self.ports = [ self.ports = [
mock.Mock(spec=['network_id', 'fixed_ips', 'network'], mock.Mock(spec=['network_id', 'fixed_ips', 'network'],
network_id=n, fixed_ips=[{'ip_address': ip}]) network_id=n, fixed_ips=[{'ip_address': ip}])
for n, ip in [('0', '192.168.0.1'), ('1', '10.0.0.2')] for n, ip in [('0', '192.168.0.1'), ('1', '10.0.0.2')]
] ]
self.conn.network.get_port.side_effect = self.ports self.api.network.get_port.side_effect = self.ports
self.nets = [ self.nets = [
mock.Mock(spec=['id', 'name'], id=str(i)) for i in range(2) mock.Mock(spec=['id', 'name'], id=str(i)) for i in range(2)
] ]
for n in self.nets: for n in self.nets:
n.name = 'name-%s' % n.id n.name = 'name-%s' % n.id
self.conn.network.get_network.side_effect = self.nets self.api.network.get_network.side_effect = self.nets
def test_ip_addresses(self): def test_ip_addresses(self):
ips = self.instance.ip_addresses() ips = self.instance.ip_addresses()
@ -70,7 +68,7 @@ class TestInstanceStates(test_provisioner.Base):
self.assertTrue(self.instance.is_healthy) self.assertTrue(self.instance.is_healthy)
def test_state_deploying_maintenance(self): def test_state_deploying_maintenance(self):
self.node.maintenance = True self.node.is_maintenance = True
self.node.provision_state = 'wait call-back' self.node.provision_state = 'wait call-back'
self.assertEqual('deploying', self.instance.state) self.assertEqual('deploying', self.instance.state)
self.assertFalse(self.instance.is_deployed) self.assertFalse(self.instance.is_deployed)
@ -83,7 +81,7 @@ class TestInstanceStates(test_provisioner.Base):
self.assertTrue(self.instance.is_healthy) self.assertTrue(self.instance.is_healthy)
def test_state_maintenance(self): def test_state_maintenance(self):
self.node.maintenance = True self.node.is_maintenance = True
self.node.provision_state = 'active' self.node.provision_state = 'active'
self.assertEqual('maintenance', self.instance.state) self.assertEqual('maintenance', self.instance.state)
self.assertTrue(self.instance.is_deployed) self.assertTrue(self.instance.is_deployed)
@ -112,5 +110,5 @@ class TestInstanceStates(test_provisioner.Base):
'ip_addresses': {'private': ['1.2.3.4']}, 'ip_addresses': {'private': ['1.2.3.4']},
'node': {'node': 'dict'}, 'node': {'node': 'dict'},
'state': 'deploying', 'state': 'deploying',
'uuid': self.node.uuid}, 'uuid': self.node.id},
self.instance.to_dict()) self.instance.to_dict())

View File

@ -1,129 +0,0 @@
# Copyright 2018 Red Hat, 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.
import fixtures
import mock
import testtools
from metalsmith import _instance
from metalsmith import _os_api
class TestNodes(testtools.TestCase):
def setUp(self):
super(TestNodes, self).setUp()
self.session = mock.Mock()
self.ironic_fixture = self.useFixture(
fixtures.MockPatchObject(_os_api.ir_client, 'get_client',
autospec=True))
self.cli = self.ironic_fixture.mock.return_value
self.api = _os_api.API(session=self.session, connection=mock.Mock())
def test_get_node_by_uuid(self):
res = self.api.get_node('uuid1')
self.cli.node.get.assert_called_once_with('uuid1')
self.assertIs(res, self.cli.node.get.return_value)
def test_get_node_by_hostname(self):
self.cli.node.list.return_value = [
mock.Mock(uuid='uuid0', instance_info={}),
mock.Mock(uuid='uuid1',
instance_info={'metalsmith_hostname': 'host1'}),
]
res = self.api.get_node('host1', accept_hostname=True)
# Loading details
self.cli.node.get.assert_called_once_with('uuid1')
self.assertIs(res, self.cli.node.get.return_value)
def test_get_node_by_hostname_not_found(self):
self.cli.node.list.return_value = [
mock.Mock(uuid='uuid0', instance_info={}),
mock.Mock(uuid='uuid1',
instance_info={'metalsmith_hostname': 'host0'}),
]
res = self.api.get_node('host1', accept_hostname=True)
# Loading details
self.cli.node.get.assert_called_once_with('host1')
self.assertIs(res, self.cli.node.get.return_value)
def test_get_node_by_node(self):
res = self.api.get_node(mock.sentinel.node)
self.assertIs(res, mock.sentinel.node)
self.assertFalse(self.cli.node.get.called)
def test_get_node_by_node_with_refresh(self):
res = self.api.get_node(mock.Mock(spec=['uuid'], uuid='uuid1'),
refresh=True)
self.cli.node.get.assert_called_once_with('uuid1')
self.assertIs(res, self.cli.node.get.return_value)
def test_get_node_by_instance(self):
inst = _instance.Instance(mock.Mock(), mock.Mock())
res = self.api.get_node(inst)
self.assertIs(res, inst.node)
self.assertFalse(self.cli.node.get.called)
def test_get_node_by_instance_with_refresh(self):
inst = _instance.Instance(mock.Mock(),
mock.Mock(spec=['uuid'], uuid='uuid1'))
res = self.api.get_node(inst, refresh=True)
self.cli.node.get.assert_called_once_with('uuid1')
self.assertIs(res, self.cli.node.get.return_value)
def test_find_node_by_hostname(self):
self.cli.node.list.return_value = [
mock.Mock(uuid='uuid0', instance_info={}),
mock.Mock(uuid='uuid1',
instance_info={'metalsmith_hostname': 'host1'}),
]
res = self.api.find_node_by_hostname('host1')
# Loading details
self.cli.node.get.assert_called_once_with('uuid1')
self.assertIs(res, self.cli.node.get.return_value)
def test_find_node_by_hostname_cached(self):
self.cli.node.list.return_value = [
mock.Mock(uuid='uuid0', instance_info={}),
mock.Mock(uuid='uuid1',
instance_info={'metalsmith_hostname': 'host1'}),
]
with self.api.cache_node_list_for_lookup():
res = self.api.find_node_by_hostname('host1')
self.assertIs(res, self.cli.node.get.return_value)
self.assertIsNone(self.api.find_node_by_hostname('host2'))
self.assertEqual(1, self.cli.node.list.call_count)
# This call is no longer cached
self.assertIsNone(self.api.find_node_by_hostname('host2'))
self.assertEqual(2, self.cli.node.list.call_count)
def test_find_node_by_hostname_not_found(self):
self.cli.node.list.return_value = [
mock.Mock(uuid='uuid0', instance_info={}),
mock.Mock(uuid='uuid1',
instance_info={'metalsmith_hostname': 'host1'}),
]
self.assertIsNone(self.api.find_node_by_hostname('host0'))
self.assertFalse(self.cli.node.get.called)
def test_find_node_by_hostname_duplicate(self):
self.cli.node.list.return_value = [
mock.Mock(uuid='uuid0',
instance_info={'metalsmith_hostname': 'host1'}),
mock.Mock(uuid='uuid1',
instance_info={'metalsmith_hostname': 'host1'}),
]
self.assertRaisesRegex(RuntimeError, 'More than one node',
self.api.find_node_by_hostname, 'host1')
self.assertFalse(self.cli.node.get.called)

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
# limitations under the License. # limitations under the License.
import mock import mock
from openstack import exceptions as sdk_exc
import testtools import testtools
from metalsmith import _scheduler from metalsmith import _scheduler
@ -24,14 +25,14 @@ class TestScheduleNode(testtools.TestCase):
def setUp(self): def setUp(self):
super(TestScheduleNode, self).setUp() super(TestScheduleNode, self).setUp()
self.nodes = [mock.Mock(spec=['uuid', 'name']) for _ in range(2)] self.nodes = [mock.Mock(spec=['id', 'name']) for _ in range(2)]
self.reserver = self._reserver(lambda x: x) self.reserver = self._reserver(lambda x: x)
def _reserver(self, side_effect): def _reserver(self, side_effect):
reserver = mock.Mock(spec=_scheduler.Reserver) reserver = mock.Mock(spec=_scheduler.Reserver)
reserver.side_effect = side_effect reserver.side_effect = side_effect
if isinstance(side_effect, Exception): if isinstance(side_effect, Exception):
reserver.fail.side_effect = RuntimeError('failed') reserver.fail.side_effect = exceptions.ReservationFailed('fail')
else: else:
reserver.fail.side_effect = AssertionError('called fail') reserver.fail.side_effect = AssertionError('called fail')
return reserver return reserver
@ -56,15 +57,16 @@ class TestScheduleNode(testtools.TestCase):
self.assertFalse(self.reserver.fail.called) self.assertFalse(self.reserver.fail.called)
def test_reservation_one_failed(self): def test_reservation_one_failed(self):
reserver = self._reserver([Exception("boom"), self.nodes[1]]) reserver = self._reserver([sdk_exc.SDKException("boom"),
self.nodes[1]])
result = _scheduler.schedule_node(self.nodes, [], reserver) result = _scheduler.schedule_node(self.nodes, [], reserver)
self.assertIs(result, self.nodes[1]) self.assertIs(result, self.nodes[1])
self.assertEqual([mock.call(n) for n in self.nodes], self.assertEqual([mock.call(n) for n in self.nodes],
reserver.call_args_list) reserver.call_args_list)
def test_reservation_all_failed(self): def test_reservation_all_failed(self):
reserver = self._reserver(Exception("boom")) reserver = self._reserver(sdk_exc.SDKException("boom"))
self.assertRaisesRegex(RuntimeError, 'failed', self.assertRaisesRegex(exceptions.ReservationFailed, 'fail',
_scheduler.schedule_node, _scheduler.schedule_node,
self.nodes, [], reserver) self.nodes, [], reserver)
self.assertEqual([mock.call(n) for n in self.nodes], self.assertEqual([mock.call(n) for n in self.nodes],
@ -121,7 +123,7 @@ class TestCapabilitiesFilter(testtools.TestCase):
def test_nothing_requested_nothing_found(self): def test_nothing_requested_nothing_found(self):
fltr = _scheduler.CapabilitiesFilter({}) fltr = _scheduler.CapabilitiesFilter({})
node = mock.Mock(properties={}, spec=['properties', 'name', 'uuid']) node = mock.Mock(properties={}, spec=['properties', 'name', 'id'])
self.assertTrue(fltr(node)) self.assertTrue(fltr(node))
def test_matching_node(self): def test_matching_node(self):
@ -129,7 +131,7 @@ class TestCapabilitiesFilter(testtools.TestCase):
'foo': 'bar'}) 'foo': 'bar'})
node = mock.Mock( node = mock.Mock(
properties={'capabilities': 'foo:bar,profile:compute,answer:42'}, properties={'capabilities': 'foo:bar,profile:compute,answer:42'},
spec=['properties', 'name', 'uuid']) spec=['properties', 'name', 'id'])
self.assertTrue(fltr(node)) self.assertTrue(fltr(node))
def test_not_matching_node(self): def test_not_matching_node(self):
@ -137,14 +139,14 @@ class TestCapabilitiesFilter(testtools.TestCase):
'foo': 'bar'}) 'foo': 'bar'})
node = mock.Mock( node = mock.Mock(
properties={'capabilities': 'foo:bar,answer:42'}, properties={'capabilities': 'foo:bar,answer:42'},
spec=['properties', 'name', 'uuid']) spec=['properties', 'name', 'id'])
self.assertFalse(fltr(node)) self.assertFalse(fltr(node))
def test_fail_message(self): def test_fail_message(self):
fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'}) fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'})
node = mock.Mock( node = mock.Mock(
properties={'capabilities': 'profile:control'}, properties={'capabilities': 'profile:control'},
spec=['properties', 'name', 'uuid']) spec=['properties', 'name', 'id'])
self.assertFalse(fltr(node)) self.assertFalse(fltr(node))
self.assertRaisesRegex(exceptions.CapabilitiesNotFound, self.assertRaisesRegex(exceptions.CapabilitiesNotFound,
'No available nodes found with capabilities ' 'No available nodes found with capabilities '
@ -156,7 +158,7 @@ class TestCapabilitiesFilter(testtools.TestCase):
fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'}) fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'})
for cap in ['foo,profile:control', 42, 'a:b:c']: for cap in ['foo,profile:control', 42, 'a:b:c']:
node = mock.Mock(properties={'capabilities': cap}, node = mock.Mock(properties={'capabilities': cap},
spec=['properties', 'name', 'uuid']) spec=['properties', 'name', 'id'])
self.assertFalse(fltr(node)) self.assertFalse(fltr(node))
self.assertRaisesRegex(exceptions.CapabilitiesNotFound, self.assertRaisesRegex(exceptions.CapabilitiesNotFound,
'No available nodes found with capabilities ' 'No available nodes found with capabilities '
@ -175,24 +177,24 @@ class TestTraitsFilter(testtools.TestCase):
def test_no_traits(self): def test_no_traits(self):
fltr = _scheduler.TraitsFilter([]) fltr = _scheduler.TraitsFilter([])
node = mock.Mock(spec=['name', 'uuid']) node = mock.Mock(spec=['name', 'id'])
self.assertTrue(fltr(node)) self.assertTrue(fltr(node))
def test_ok(self): def test_ok(self):
fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) fltr = _scheduler.TraitsFilter(['tr1', 'tr2'])
node = mock.Mock(spec=['name', 'uuid', 'traits'], node = mock.Mock(spec=['name', 'id', 'traits'],
traits=['tr3', 'tr2', 'tr1']) traits=['tr3', 'tr2', 'tr1'])
self.assertTrue(fltr(node)) self.assertTrue(fltr(node))
def test_missing_one(self): def test_missing_one(self):
fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) fltr = _scheduler.TraitsFilter(['tr1', 'tr2'])
node = mock.Mock(spec=['name', 'uuid', 'traits'], node = mock.Mock(spec=['name', 'id', 'traits'],
traits=['tr3', 'tr1']) traits=['tr3', 'tr1'])
self.assertFalse(fltr(node)) self.assertFalse(fltr(node))
def test_missing_all(self): def test_missing_all(self):
fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) fltr = _scheduler.TraitsFilter(['tr1', 'tr2'])
node = mock.Mock(spec=['name', 'uuid', 'traits'], traits=None) node = mock.Mock(spec=['name', 'id', 'traits'], traits=None)
self.assertFalse(fltr(node)) self.assertFalse(fltr(node))
@ -200,10 +202,12 @@ class TestIronicReserver(testtools.TestCase):
def setUp(self): def setUp(self):
super(TestIronicReserver, self).setUp() super(TestIronicReserver, self).setUp()
self.node = mock.Mock(spec=['uuid', 'name']) self.node = mock.Mock(spec=['id', 'name', 'instance_info'],
self.api = mock.Mock(spec=['reserve_node', 'release_node', instance_info={})
'validate_node']) self.api = mock.Mock(spec=['baremetal'])
self.api.reserve_node.side_effect = lambda node, instance_uuid: node self.api.baremetal = mock.Mock(spec=['update_node', 'validate_node'])
self.api.baremetal.update_node.side_effect = (
lambda node, **kw: node)
self.reserver = _scheduler.IronicReserver(self.api) self.reserver = _scheduler.IronicReserver(self.api)
def test_fail(self): def test_fail(self):
@ -213,22 +217,36 @@ class TestIronicReserver(testtools.TestCase):
def test_ok(self): def test_ok(self):
self.assertEqual(self.node, self.reserver(self.node)) self.assertEqual(self.node, self.reserver(self.node))
self.api.validate_node.assert_called_with(self.node) self.api.baremetal.validate_node.assert_called_with(
self.api.reserve_node.assert_called_once_with( self.node, required=('power', 'management'))
self.node, instance_uuid=self.node.uuid) self.api.baremetal.update_node.assert_called_once_with(
self.node, instance_id=self.node.id, instance_info={})
def test_with_instance_info(self):
self.reserver = _scheduler.IronicReserver(self.api,
{'cat': 'meow'})
self.assertEqual(self.node, self.reserver(self.node))
self.api.baremetal.validate_node.assert_called_with(
self.node, required=('power', 'management'))
self.api.baremetal.update_node.assert_called_once_with(
self.node, instance_id=self.node.id,
instance_info={'cat': 'meow'})
def test_reservation_failed(self): def test_reservation_failed(self):
self.api.reserve_node.side_effect = RuntimeError('conflict') self.api.baremetal.update_node.side_effect = (
self.assertRaisesRegex(RuntimeError, 'conflict', sdk_exc.SDKException('conflict'))
self.assertRaisesRegex(sdk_exc.SDKException, 'conflict',
self.reserver, self.node) self.reserver, self.node)
self.api.validate_node.assert_called_with(self.node) self.api.baremetal.validate_node.assert_called_with(
self.api.reserve_node.assert_called_once_with( self.node, required=('power', 'management'))
self.node, instance_uuid=self.node.uuid) self.api.baremetal.update_node.assert_called_once_with(
self.node, instance_id=self.node.id, instance_info={})
def test_validation_failed(self): def test_validation_failed(self):
self.api.validate_node.side_effect = RuntimeError('fail') self.api.baremetal.validate_node.side_effect = (
sdk_exc.SDKException('fail'))
self.assertRaisesRegex(exceptions.ValidationFailed, 'fail', self.assertRaisesRegex(exceptions.ValidationFailed, 'fail',
self.reserver, self.node) self.reserver, self.node)
self.api.validate_node.assert_called_once_with(self.node) self.api.baremetal.validate_node.assert_called_with(
self.assertFalse(self.api.reserve_node.called) self.node, required=('power', 'management'))
self.assertFalse(self.api.release_node.called) self.assertFalse(self.api.baremetal.update_node.called)

View File

@ -19,7 +19,7 @@
include_role: include_role:
name: metalsmith_deployment name: metalsmith_deployment
vars: vars:
metalsmith_extra_args: -vv metalsmith_extra_args: --debug
metalsmith_resource_class: baremetal metalsmith_resource_class: baremetal
metalsmith_instances: metalsmith_instances:
- hostname: test - hostname: test
@ -48,7 +48,7 @@
failed_when: instance_via_list.state != 'active' or instance_via_list.node.provision_state != 'active' failed_when: instance_via_list.state != 'active' or instance_via_list.node.provision_state != 'active'
- name: Show active node information - name: Show active node information
command: openstack baremetal node show {{ instance.node.uuid }} command: openstack baremetal node show {{ instance.node.id }}
- name: Get IP address - name: Get IP address
set_fact: set_fact:
@ -69,7 +69,7 @@
command: metalsmith --debug undeploy --wait 900 test command: metalsmith --debug undeploy --wait 900 test
- name: Get the current status of the deployed node - name: Get the current status of the deployed node
command: openstack baremetal node show {{ instance.node.uuid }} -f json command: openstack baremetal node show {{ instance.node.id }} -f json
register: undeployed_node_result register: undeployed_node_result
- name: Parse node state - name: Parse node state
@ -87,7 +87,7 @@
when: undeployed_node.extra != {} when: undeployed_node.extra != {}
- name: Get attached VIFs for the node - name: Get attached VIFs for the node
command: openstack baremetal node vif list {{ instance.node.uuid }} -f value -c ID command: openstack baremetal node vif list {{ instance.node.id }} -f value -c ID
register: vif_list_output register: vif_list_output
- name: Check that no VIFs are still attached - name: Check that no VIFs are still attached

View File

@ -2,7 +2,6 @@
# of appearance. Changing the order has an impact on the overall integration # of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later. # process, which may cause wedges in the gate later.
pbr!=2.1.0,>=2.0.0 # Apache-2.0 pbr!=2.1.0,>=2.0.0 # Apache-2.0
openstacksdk>=0.17.0 # Apache-2.0 openstacksdk>=0.22.0 # Apache-2.0
python-ironicclient>=1.14.0 # Apache-2.0
requests>=2.18.4 # Apache-2.0 requests>=2.18.4 # Apache-2.0
six>=1.10.0 # MIT six>=1.10.0 # MIT