Merge "Expose conductors: api"
This commit is contained in:
commit
7b1ca21ac4
@ -2,6 +2,12 @@
|
|||||||
REST API Version History
|
REST API Version History
|
||||||
========================
|
========================
|
||||||
|
|
||||||
|
1.49 (Stein, master)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Added new endpoints for retrieving conductors information, and added a
|
||||||
|
``conductor`` field to node object.
|
||||||
|
|
||||||
1.48 (Stein, master)
|
1.48 (Stein, master)
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ from wsme import types as wtypes
|
|||||||
from ironic.api.controllers import base
|
from ironic.api.controllers import base
|
||||||
from ironic.api.controllers import link
|
from ironic.api.controllers import link
|
||||||
from ironic.api.controllers.v1 import chassis
|
from ironic.api.controllers.v1 import chassis
|
||||||
|
from ironic.api.controllers.v1 import conductor
|
||||||
from ironic.api.controllers.v1 import driver
|
from ironic.api.controllers.v1 import driver
|
||||||
from ironic.api.controllers.v1 import node
|
from ironic.api.controllers.v1 import node
|
||||||
from ironic.api.controllers.v1 import port
|
from ironic.api.controllers.v1 import port
|
||||||
@ -100,6 +101,9 @@ class V1(base.APIBase):
|
|||||||
heartbeat = [link.Link]
|
heartbeat = [link.Link]
|
||||||
"""Links to the heartbeat resource"""
|
"""Links to the heartbeat resource"""
|
||||||
|
|
||||||
|
conductors = [link.Link]
|
||||||
|
"""Links to the conductors resource"""
|
||||||
|
|
||||||
version = version.Version
|
version = version.Version
|
||||||
"""Version discovery information."""
|
"""Version discovery information."""
|
||||||
|
|
||||||
@ -178,6 +182,15 @@ class V1(base.APIBase):
|
|||||||
'heartbeat', '',
|
'heartbeat', '',
|
||||||
bookmark=True)
|
bookmark=True)
|
||||||
]
|
]
|
||||||
|
if utils.allow_expose_conductors():
|
||||||
|
v1.conductors = [link.Link.make_link('self',
|
||||||
|
pecan.request.public_url,
|
||||||
|
'conductors', ''),
|
||||||
|
link.Link.make_link('bookmark',
|
||||||
|
pecan.request.public_url,
|
||||||
|
'conductors', '',
|
||||||
|
bookmark=True)
|
||||||
|
]
|
||||||
v1.version = version.default_version()
|
v1.version = version.default_version()
|
||||||
return v1
|
return v1
|
||||||
|
|
||||||
@ -193,6 +206,7 @@ class Controller(rest.RestController):
|
|||||||
volume = volume.VolumeController()
|
volume = volume.VolumeController()
|
||||||
lookup = ramdisk.LookupController()
|
lookup = ramdisk.LookupController()
|
||||||
heartbeat = ramdisk.HeartbeatController()
|
heartbeat = ramdisk.HeartbeatController()
|
||||||
|
conductors = conductor.ConductorsController()
|
||||||
|
|
||||||
@expose.expose(V1)
|
@expose.expose(V1)
|
||||||
def get(self):
|
def get(self):
|
||||||
|
@ -29,6 +29,10 @@ class Collection(base.APIBase):
|
|||||||
def collection(self):
|
def collection(self):
|
||||||
return getattr(self, self._type)
|
return getattr(self, self._type)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_key_field(cls):
|
||||||
|
return 'uuid'
|
||||||
|
|
||||||
def has_next(self, limit):
|
def has_next(self, limit):
|
||||||
"""Return whether collection has more items."""
|
"""Return whether collection has more items."""
|
||||||
return len(self.collection) and len(self.collection) == limit
|
return len(self.collection) and len(self.collection) == limit
|
||||||
@ -42,7 +46,7 @@ class Collection(base.APIBase):
|
|||||||
q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs])
|
q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs])
|
||||||
next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % {
|
next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % {
|
||||||
'args': q_args, 'limit': limit,
|
'args': q_args, 'limit': limit,
|
||||||
'marker': self.collection[-1].uuid}
|
'marker': getattr(self.collection[-1], self.get_key_field())}
|
||||||
|
|
||||||
return link.Link.make_link('next', pecan.request.public_url,
|
return link.Link.make_link('next', pecan.request.public_url,
|
||||||
resource_url, next_args).href
|
resource_url, next_args).href
|
||||||
|
251
ironic/api/controllers/v1/conductor.py
Normal file
251
ironic/api/controllers/v1/conductor.py
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# 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 datetime
|
||||||
|
|
||||||
|
from ironic_lib import metrics_utils
|
||||||
|
from oslo_log import log
|
||||||
|
from oslo_utils import timeutils
|
||||||
|
import pecan
|
||||||
|
from pecan import rest
|
||||||
|
import wsme
|
||||||
|
from wsme import types as wtypes
|
||||||
|
|
||||||
|
from ironic.api.controllers import base
|
||||||
|
from ironic.api.controllers import link
|
||||||
|
from ironic.api.controllers.v1 import collection
|
||||||
|
from ironic.api.controllers.v1 import types
|
||||||
|
from ironic.api.controllers.v1 import utils as api_utils
|
||||||
|
from ironic.api import expose
|
||||||
|
from ironic.common import exception
|
||||||
|
from ironic.common.i18n import _
|
||||||
|
from ironic.common import policy
|
||||||
|
import ironic.conf
|
||||||
|
from ironic import objects
|
||||||
|
|
||||||
|
CONF = ironic.conf.CONF
|
||||||
|
LOG = log.getLogger(__name__)
|
||||||
|
METRICS = metrics_utils.get_metrics_logger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_RETURN_FIELDS = ('hostname', 'conductor_group', 'alive')
|
||||||
|
|
||||||
|
|
||||||
|
class Conductor(base.APIBase):
|
||||||
|
"""API representation of a bare metal conductor."""
|
||||||
|
|
||||||
|
hostname = wsme.wsattr(wtypes.text)
|
||||||
|
"""The hostname for this conductor"""
|
||||||
|
|
||||||
|
conductor_group = wsme.wsattr(wtypes.text)
|
||||||
|
"""The conductor group this conductor belongs to"""
|
||||||
|
|
||||||
|
alive = types.boolean
|
||||||
|
"""Indicates whether this conductor is considered alive"""
|
||||||
|
|
||||||
|
drivers = wsme.wsattr([wtypes.text])
|
||||||
|
"""The drivers enabled on this conductor"""
|
||||||
|
|
||||||
|
links = wsme.wsattr([link.Link])
|
||||||
|
"""A list containing a self link and associated conductor links"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.fields = []
|
||||||
|
fields = list(objects.Conductor.fields)
|
||||||
|
# NOTE(kaifeng): alive is not part of objects.Conductor.fields
|
||||||
|
# because it's an API-only attribute.
|
||||||
|
fields.append('alive')
|
||||||
|
|
||||||
|
for field in fields:
|
||||||
|
# Skip fields we do not expose.
|
||||||
|
if not hasattr(self, field):
|
||||||
|
continue
|
||||||
|
self.fields.append(field)
|
||||||
|
setattr(self, field, kwargs.get(field, wtypes.Unset))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_with_links(conductor, url, fields=None):
|
||||||
|
conductor.links = [link.Link.make_link('self', url, 'conductors',
|
||||||
|
conductor.hostname),
|
||||||
|
link.Link.make_link('bookmark', url, 'conductors',
|
||||||
|
conductor.hostname,
|
||||||
|
bookmark=True)]
|
||||||
|
return conductor
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def convert_with_links(cls, rpc_conductor, fields=None):
|
||||||
|
conductor = Conductor(**rpc_conductor.as_dict())
|
||||||
|
conductor.alive = not timeutils.is_older_than(
|
||||||
|
conductor.updated_at, CONF.conductor.heartbeat_timeout)
|
||||||
|
|
||||||
|
if fields is not None:
|
||||||
|
api_utils.check_for_invalid_fields(fields, conductor.as_dict())
|
||||||
|
|
||||||
|
conductor = cls._convert_with_links(conductor,
|
||||||
|
pecan.request.public_url,
|
||||||
|
fields=fields)
|
||||||
|
conductor.sanitize(fields)
|
||||||
|
return conductor
|
||||||
|
|
||||||
|
def sanitize(self, fields):
|
||||||
|
"""Removes sensitive and unrequested data.
|
||||||
|
|
||||||
|
Will only keep the fields specified in the ``fields`` parameter.
|
||||||
|
|
||||||
|
:param fields:
|
||||||
|
list of fields to preserve, or ``None`` to preserve them all
|
||||||
|
:type fields: list of str
|
||||||
|
"""
|
||||||
|
if fields is not None:
|
||||||
|
self.unset_fields_except(fields)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sample(cls, expand=True):
|
||||||
|
time = datetime.datetime(2000, 1, 1, 12, 0, 0)
|
||||||
|
sample = cls(hostname='computer01',
|
||||||
|
conductor_group='',
|
||||||
|
alive=True,
|
||||||
|
drivers=['ipmi'],
|
||||||
|
created_at=time,
|
||||||
|
updated_at=time)
|
||||||
|
fields = None if expand else _DEFAULT_RETURN_FIELDS
|
||||||
|
return cls._convert_with_links(sample, 'http://localhost:6385',
|
||||||
|
fields=fields)
|
||||||
|
|
||||||
|
|
||||||
|
class ConductorCollection(collection.Collection):
|
||||||
|
"""API representation of a collection of conductors."""
|
||||||
|
|
||||||
|
conductors = [Conductor]
|
||||||
|
"""A list containing conductor objects"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self._type = 'conductors'
|
||||||
|
|
||||||
|
# NOTE(kaifeng) Override because conductors use hostname instead of uuid.
|
||||||
|
@classmethod
|
||||||
|
def get_key_field(cls):
|
||||||
|
return 'hostname'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert_with_links(conductors, limit, url=None, fields=None, **kwargs):
|
||||||
|
collection = ConductorCollection()
|
||||||
|
collection.conductors = [Conductor.convert_with_links(c, fields=fields)
|
||||||
|
for c in conductors]
|
||||||
|
collection.next = collection.get_next(limit, url=url, **kwargs)
|
||||||
|
|
||||||
|
for conductor in collection.conductors:
|
||||||
|
conductor.sanitize(fields)
|
||||||
|
|
||||||
|
return collection
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sample(cls):
|
||||||
|
sample = cls()
|
||||||
|
conductor = Conductor.sample(expand=False)
|
||||||
|
sample.conductors = [conductor]
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
|
class ConductorsController(rest.RestController):
|
||||||
|
"""REST controller for conductors."""
|
||||||
|
|
||||||
|
invalid_sort_key_list = ['alive', 'drivers']
|
||||||
|
|
||||||
|
def _get_conductors_collection(self, marker, limit, sort_key, sort_dir,
|
||||||
|
resource_url=None, fields=None,
|
||||||
|
detail=None):
|
||||||
|
|
||||||
|
limit = api_utils.validate_limit(limit)
|
||||||
|
sort_dir = api_utils.validate_sort_dir(sort_dir)
|
||||||
|
|
||||||
|
if sort_key in self.invalid_sort_key_list:
|
||||||
|
raise exception.InvalidParameterValue(
|
||||||
|
_("The sort_key value %(key)s is an invalid field for "
|
||||||
|
"sorting") % {'key': sort_key})
|
||||||
|
|
||||||
|
marker_obj = None
|
||||||
|
if marker:
|
||||||
|
marker_obj = objects.Conductor.get_by_hostname(
|
||||||
|
pecan.request.context, marker, online=None)
|
||||||
|
|
||||||
|
conductors = objects.Conductor.list(pecan.request.context, limit=limit,
|
||||||
|
marker=marker_obj,
|
||||||
|
sort_key=sort_key,
|
||||||
|
sort_dir=sort_dir)
|
||||||
|
|
||||||
|
parameters = {'sort_key': sort_key, 'sort_dir': sort_dir}
|
||||||
|
|
||||||
|
if detail is not None:
|
||||||
|
parameters['detail'] = detail
|
||||||
|
|
||||||
|
return ConductorCollection.convert_with_links(conductors, limit,
|
||||||
|
url=resource_url,
|
||||||
|
fields=fields,
|
||||||
|
**parameters)
|
||||||
|
|
||||||
|
@METRICS.timer('ConductorsController.get_all')
|
||||||
|
@expose.expose(ConductorCollection, types.name, int, wtypes.text,
|
||||||
|
wtypes.text, types.listtype, types.boolean)
|
||||||
|
def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc',
|
||||||
|
fields=None, detail=None):
|
||||||
|
"""Retrieve a list of conductors.
|
||||||
|
|
||||||
|
:param marker: pagination marker for large data sets.
|
||||||
|
:param limit: maximum number of resources to return in a single result.
|
||||||
|
This value cannot be larger than the value of max_limit
|
||||||
|
in the [api] section of the ironic configuration, or only
|
||||||
|
max_limit resources will be returned.
|
||||||
|
:param sort_key: column to sort results by. Default: id.
|
||||||
|
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
|
||||||
|
:param fields: Optional, a list with a specified set of fields
|
||||||
|
of the resource to be returned.
|
||||||
|
:param detail: Optional, boolean to indicate whether retrieve a list
|
||||||
|
of conductors with detail.
|
||||||
|
"""
|
||||||
|
cdict = pecan.request.context.to_policy_values()
|
||||||
|
policy.authorize('baremetal:conductor:get', cdict, cdict)
|
||||||
|
|
||||||
|
if not api_utils.allow_expose_conductors():
|
||||||
|
raise exception.NotFound()
|
||||||
|
|
||||||
|
api_utils.check_allow_specify_fields(fields)
|
||||||
|
api_utils.check_allowed_fields(fields)
|
||||||
|
api_utils.check_allowed_fields([sort_key])
|
||||||
|
|
||||||
|
fields = api_utils.get_request_return_fields(fields, detail,
|
||||||
|
_DEFAULT_RETURN_FIELDS)
|
||||||
|
|
||||||
|
return self._get_conductors_collection(marker, limit, sort_key,
|
||||||
|
sort_dir, fields=fields,
|
||||||
|
detail=detail)
|
||||||
|
|
||||||
|
@METRICS.timer('ConductorsController.get_one')
|
||||||
|
@expose.expose(Conductor, types.name, types.listtype)
|
||||||
|
def get_one(self, hostname, fields=None):
|
||||||
|
"""Retrieve information about the given conductor.
|
||||||
|
|
||||||
|
:param hostname: hostname of a conductor.
|
||||||
|
:param fields: Optional, a list with a specified set of fields
|
||||||
|
of the resource to be returned.
|
||||||
|
"""
|
||||||
|
cdict = pecan.request.context.to_policy_values()
|
||||||
|
policy.authorize('baremetal:conductor:get', cdict, cdict)
|
||||||
|
|
||||||
|
if not api_utils.allow_expose_conductors():
|
||||||
|
raise exception.NotFound()
|
||||||
|
|
||||||
|
api_utils.check_allow_specify_fields(fields)
|
||||||
|
api_utils.check_allowed_fields(fields)
|
||||||
|
|
||||||
|
conductor = objects.Conductor.get_by_hostname(pecan.request.context,
|
||||||
|
hostname, online=None)
|
||||||
|
return Conductor.convert_with_links(conductor, fields=fields)
|
@ -1072,6 +1072,9 @@ class Node(base.APIBase):
|
|||||||
protected_reason = wsme.wsattr(wtypes.text)
|
protected_reason = wsme.wsattr(wtypes.text)
|
||||||
"""Indicates reason for protecting the node."""
|
"""Indicates reason for protecting the node."""
|
||||||
|
|
||||||
|
conductor = wsme.wsattr(wtypes.text, readonly=True)
|
||||||
|
"""Represent the conductor currently serving the node"""
|
||||||
|
|
||||||
# NOTE(deva): "conductor_affinity" shouldn't be presented on the
|
# NOTE(deva): "conductor_affinity" shouldn't be presented on the
|
||||||
# API because it's an internal value. Don't add it here.
|
# API because it's an internal value. Don't add it here.
|
||||||
|
|
||||||
@ -1081,6 +1084,8 @@ class Node(base.APIBase):
|
|||||||
# NOTE(lucasagomes): chassis_uuid is not part of objects.Node.fields
|
# NOTE(lucasagomes): chassis_uuid is not part of objects.Node.fields
|
||||||
# because it's an API-only attribute.
|
# because it's an API-only attribute.
|
||||||
fields.append('chassis_uuid')
|
fields.append('chassis_uuid')
|
||||||
|
# NOTE(kaifeng) conductor is not part of objects.Node.fields too.
|
||||||
|
fields.append('conductor')
|
||||||
for k in fields:
|
for k in fields:
|
||||||
# Add fields we expose.
|
# Add fields we expose.
|
||||||
if hasattr(self, k):
|
if hasattr(self, k):
|
||||||
@ -1149,6 +1154,18 @@ class Node(base.APIBase):
|
|||||||
def convert_with_links(cls, rpc_node, fields=None, sanitize=True):
|
def convert_with_links(cls, rpc_node, fields=None, sanitize=True):
|
||||||
node = Node(**rpc_node.as_dict())
|
node = Node(**rpc_node.as_dict())
|
||||||
|
|
||||||
|
if (api_utils.allow_expose_conductors() and
|
||||||
|
(fields is None or 'conductor' in fields)):
|
||||||
|
# NOTE(kaifeng) It is possible a node gets orphaned in certain
|
||||||
|
# circumstances, set conductor to None in such case.
|
||||||
|
try:
|
||||||
|
host = pecan.request.rpcapi.get_conductor_for(rpc_node)
|
||||||
|
node.conductor = host
|
||||||
|
except (exception.NoValidHost, exception.TemporaryFailure):
|
||||||
|
LOG.debug('Currently there is no conductor servicing node '
|
||||||
|
'%(node)s.', {'node': rpc_node.uuid})
|
||||||
|
node.conductor = None
|
||||||
|
|
||||||
if fields is not None:
|
if fields is not None:
|
||||||
api_utils.check_for_invalid_fields(fields, node.as_dict())
|
api_utils.check_for_invalid_fields(fields, node.as_dict())
|
||||||
|
|
||||||
@ -1286,7 +1303,7 @@ class NodePatchType(types.JsonPatchType):
|
|||||||
'/inspection_started_at', '/clean_step',
|
'/inspection_started_at', '/clean_step',
|
||||||
'/deploy_step',
|
'/deploy_step',
|
||||||
'/raid_config', '/target_raid_config',
|
'/raid_config', '/target_raid_config',
|
||||||
'/fault']
|
'/fault', '/conductor']
|
||||||
|
|
||||||
|
|
||||||
class NodeCollection(collection.Collection):
|
class NodeCollection(collection.Collection):
|
||||||
@ -1564,44 +1581,18 @@ class NodesController(rest.RestController):
|
|||||||
if subcontroller:
|
if subcontroller:
|
||||||
return subcontroller(node_ident=ident), remainder[1:]
|
return subcontroller(node_ident=ident), remainder[1:]
|
||||||
|
|
||||||
def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated,
|
def _filter_by_conductor(self, nodes, conductor):
|
||||||
maintenance, provision_state, marker, limit,
|
filtered_nodes = []
|
||||||
sort_key, sort_dir, driver=None,
|
for n in nodes:
|
||||||
resource_class=None, resource_url=None,
|
host = pecan.request.rpcapi.get_conductor_for(n)
|
||||||
fields=None, fault=None, conductor_group=None,
|
if host == conductor:
|
||||||
detail=None):
|
filtered_nodes.append(n)
|
||||||
if self.from_chassis and not chassis_uuid:
|
return filtered_nodes
|
||||||
raise exception.MissingParameterValue(
|
|
||||||
_("Chassis id not specified."))
|
|
||||||
|
|
||||||
limit = api_utils.validate_limit(limit)
|
def _create_node_filters(self, chassis_uuid=None, associated=None,
|
||||||
sort_dir = api_utils.validate_sort_dir(sort_dir)
|
maintenance=None, provision_state=None,
|
||||||
|
driver=None, resource_class=None, fault=None,
|
||||||
marker_obj = None
|
conductor_group=None):
|
||||||
if marker:
|
|
||||||
marker_obj = objects.Node.get_by_uuid(pecan.request.context,
|
|
||||||
marker)
|
|
||||||
|
|
||||||
if sort_key in self.invalid_sort_key_list:
|
|
||||||
raise exception.InvalidParameterValue(
|
|
||||||
_("The sort_key value %(key)s is an invalid field for "
|
|
||||||
"sorting") % {'key': sort_key})
|
|
||||||
|
|
||||||
# The query parameters for the 'next' URL
|
|
||||||
parameters = {}
|
|
||||||
|
|
||||||
if instance_uuid:
|
|
||||||
# NOTE(rloo) if instance_uuid is specified, the other query
|
|
||||||
# parameters are ignored. Since there can be at most one node that
|
|
||||||
# has this instance_uuid, we do not want to generate a 'next' link.
|
|
||||||
|
|
||||||
nodes = self._get_nodes_by_instance(instance_uuid)
|
|
||||||
|
|
||||||
# NOTE(rloo) if limit==1 and len(nodes)==1 (see
|
|
||||||
# Collection.has_next()), a 'next' link will
|
|
||||||
# be generated, which we don't want.
|
|
||||||
limit = 0
|
|
||||||
else:
|
|
||||||
filters = {}
|
filters = {}
|
||||||
if chassis_uuid:
|
if chassis_uuid:
|
||||||
filters['chassis_uuid'] = chassis_uuid
|
filters['chassis_uuid'] = chassis_uuid
|
||||||
@ -1619,11 +1610,58 @@ class NodesController(rest.RestController):
|
|||||||
filters['fault'] = fault
|
filters['fault'] = fault
|
||||||
if conductor_group is not None:
|
if conductor_group is not None:
|
||||||
filters['conductor_group'] = conductor_group
|
filters['conductor_group'] = conductor_group
|
||||||
|
return filters
|
||||||
|
|
||||||
|
def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated,
|
||||||
|
maintenance, provision_state, marker, limit,
|
||||||
|
sort_key, sort_dir, driver=None,
|
||||||
|
resource_class=None, resource_url=None,
|
||||||
|
fields=None, fault=None, conductor_group=None,
|
||||||
|
detail=None, conductor=None):
|
||||||
|
if self.from_chassis and not chassis_uuid:
|
||||||
|
raise exception.MissingParameterValue(
|
||||||
|
_("Chassis id not specified."))
|
||||||
|
|
||||||
|
limit = api_utils.validate_limit(limit)
|
||||||
|
sort_dir = api_utils.validate_sort_dir(sort_dir)
|
||||||
|
|
||||||
|
if sort_key in self.invalid_sort_key_list:
|
||||||
|
raise exception.InvalidParameterValue(
|
||||||
|
_("The sort_key value %(key)s is an invalid field for "
|
||||||
|
"sorting") % {'key': sort_key})
|
||||||
|
|
||||||
|
marker_obj = None
|
||||||
|
if marker:
|
||||||
|
marker_obj = objects.Node.get_by_uuid(pecan.request.context,
|
||||||
|
marker)
|
||||||
|
|
||||||
|
# The query parameters for the 'next' URL
|
||||||
|
parameters = {}
|
||||||
|
|
||||||
|
if instance_uuid:
|
||||||
|
# NOTE(rloo) if instance_uuid is specified, the other query
|
||||||
|
# parameters are ignored. Since there can be at most one node that
|
||||||
|
# has this instance_uuid, we do not want to generate a 'next' link.
|
||||||
|
|
||||||
|
nodes = self._get_nodes_by_instance(instance_uuid)
|
||||||
|
|
||||||
|
# NOTE(rloo) if limit==1 and len(nodes)==1 (see
|
||||||
|
# Collection.has_next()), a 'next' link will
|
||||||
|
# be generated, which we don't want.
|
||||||
|
limit = 0
|
||||||
|
else:
|
||||||
|
filters = self._create_node_filters(chassis_uuid, associated,
|
||||||
|
maintenance, provision_state,
|
||||||
|
driver, resource_class, fault,
|
||||||
|
conductor_group)
|
||||||
nodes = objects.Node.list(pecan.request.context, limit, marker_obj,
|
nodes = objects.Node.list(pecan.request.context, limit, marker_obj,
|
||||||
sort_key=sort_key, sort_dir=sort_dir,
|
sort_key=sort_key, sort_dir=sort_dir,
|
||||||
filters=filters)
|
filters=filters)
|
||||||
|
|
||||||
|
# Special filtering on results based on conductor field
|
||||||
|
if conductor:
|
||||||
|
nodes = self._filter_by_conductor(nodes, conductor)
|
||||||
|
|
||||||
parameters = {'sort_key': sort_key, 'sort_dir': sort_dir}
|
parameters = {'sort_key': sort_key, 'sort_dir': sort_dir}
|
||||||
if associated:
|
if associated:
|
||||||
parameters['associated'] = associated
|
parameters['associated'] = associated
|
||||||
@ -1726,12 +1764,12 @@ class NodesController(rest.RestController):
|
|||||||
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
|
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
|
||||||
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
|
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
|
||||||
wtypes.text, wtypes.text, types.listtype, wtypes.text,
|
wtypes.text, wtypes.text, types.listtype, wtypes.text,
|
||||||
wtypes.text, wtypes.text, types.boolean)
|
wtypes.text, wtypes.text, types.boolean, wtypes.text)
|
||||||
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
|
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
|
||||||
maintenance=None, provision_state=None, marker=None,
|
maintenance=None, provision_state=None, marker=None,
|
||||||
limit=None, sort_key='id', sort_dir='asc', driver=None,
|
limit=None, sort_key='id', sort_dir='asc', driver=None,
|
||||||
fields=None, resource_class=None, fault=None,
|
fields=None, resource_class=None, fault=None,
|
||||||
conductor_group=None, detail=None):
|
conductor_group=None, detail=None, conductor=None):
|
||||||
"""Retrieve a list of nodes.
|
"""Retrieve a list of nodes.
|
||||||
|
|
||||||
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
|
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
|
||||||
@ -1759,6 +1797,8 @@ class NodesController(rest.RestController):
|
|||||||
that resource_class.
|
that resource_class.
|
||||||
:param conductor_group: Optional string value to get only nodes with
|
:param conductor_group: Optional string value to get only nodes with
|
||||||
that conductor_group.
|
that conductor_group.
|
||||||
|
:param conductor: Optional string value to get only nodes managed by
|
||||||
|
that conductor.
|
||||||
:param fields: Optional, a list with a specified set of fields
|
:param fields: Optional, a list with a specified set of fields
|
||||||
of the resource to be returned.
|
of the resource to be returned.
|
||||||
:param fault: Optional string value to get only nodes with that fault.
|
:param fault: Optional string value to get only nodes with that fault.
|
||||||
@ -1774,6 +1814,7 @@ class NodesController(rest.RestController):
|
|||||||
api_utils.check_allow_specify_resource_class(resource_class)
|
api_utils.check_allow_specify_resource_class(resource_class)
|
||||||
api_utils.check_allow_filter_by_fault(fault)
|
api_utils.check_allow_filter_by_fault(fault)
|
||||||
api_utils.check_allow_filter_by_conductor_group(conductor_group)
|
api_utils.check_allow_filter_by_conductor_group(conductor_group)
|
||||||
|
api_utils.check_allow_filter_by_conductor(conductor)
|
||||||
|
|
||||||
fields = api_utils.get_request_return_fields(fields, detail,
|
fields = api_utils.get_request_return_fields(fields, detail,
|
||||||
_DEFAULT_RETURN_FIELDS)
|
_DEFAULT_RETURN_FIELDS)
|
||||||
@ -1786,17 +1827,19 @@ class NodesController(rest.RestController):
|
|||||||
resource_class=resource_class,
|
resource_class=resource_class,
|
||||||
fields=fields, fault=fault,
|
fields=fields, fault=fault,
|
||||||
conductor_group=conductor_group,
|
conductor_group=conductor_group,
|
||||||
detail=detail)
|
detail=detail,
|
||||||
|
conductor=conductor)
|
||||||
|
|
||||||
@METRICS.timer('NodesController.detail')
|
@METRICS.timer('NodesController.detail')
|
||||||
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
|
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
|
||||||
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
|
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
|
||||||
wtypes.text, wtypes.text, wtypes.text, wtypes.text,
|
wtypes.text, wtypes.text, wtypes.text, wtypes.text,
|
||||||
wtypes.text)
|
wtypes.text, wtypes.text)
|
||||||
def detail(self, chassis_uuid=None, instance_uuid=None, associated=None,
|
def detail(self, chassis_uuid=None, instance_uuid=None, associated=None,
|
||||||
maintenance=None, provision_state=None, marker=None,
|
maintenance=None, provision_state=None, marker=None,
|
||||||
limit=None, sort_key='id', sort_dir='asc', driver=None,
|
limit=None, sort_key='id', sort_dir='asc', driver=None,
|
||||||
resource_class=None, fault=None, conductor_group=None):
|
resource_class=None, fault=None, conductor_group=None,
|
||||||
|
conductor=None):
|
||||||
"""Retrieve a list of nodes with detail.
|
"""Retrieve a list of nodes with detail.
|
||||||
|
|
||||||
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
|
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
|
||||||
@ -1840,6 +1883,8 @@ class NodesController(rest.RestController):
|
|||||||
if parent != "nodes":
|
if parent != "nodes":
|
||||||
raise exception.HTTPNotFound()
|
raise exception.HTTPNotFound()
|
||||||
|
|
||||||
|
api_utils.check_allow_filter_by_conductor(conductor)
|
||||||
|
|
||||||
resource_url = '/'.join(['nodes', 'detail'])
|
resource_url = '/'.join(['nodes', 'detail'])
|
||||||
return self._get_nodes_collection(chassis_uuid, instance_uuid,
|
return self._get_nodes_collection(chassis_uuid, instance_uuid,
|
||||||
associated, maintenance,
|
associated, maintenance,
|
||||||
@ -1849,7 +1894,8 @@ class NodesController(rest.RestController):
|
|||||||
resource_class=resource_class,
|
resource_class=resource_class,
|
||||||
resource_url=resource_url,
|
resource_url=resource_url,
|
||||||
fault=fault,
|
fault=fault,
|
||||||
conductor_group=conductor_group)
|
conductor_group=conductor_group,
|
||||||
|
conductor=conductor)
|
||||||
|
|
||||||
@METRICS.timer('NodesController.validate')
|
@METRICS.timer('NodesController.validate')
|
||||||
@expose.expose(wtypes.text, types.uuid_or_name, types.uuid)
|
@expose.expose(wtypes.text, types.uuid_or_name, types.uuid)
|
||||||
@ -1913,6 +1959,10 @@ class NodesController(rest.RestController):
|
|||||||
if self.from_chassis:
|
if self.from_chassis:
|
||||||
raise exception.OperationNotPermitted()
|
raise exception.OperationNotPermitted()
|
||||||
|
|
||||||
|
if node.conductor is not wtypes.Unset:
|
||||||
|
msg = _("Cannot specify conductor on node creation.")
|
||||||
|
raise exception.Invalid(msg)
|
||||||
|
|
||||||
reject_fields_in_newer_versions(node)
|
reject_fields_in_newer_versions(node)
|
||||||
|
|
||||||
if node.traits is not wtypes.Unset:
|
if node.traits is not wtypes.Unset:
|
||||||
|
@ -378,6 +378,7 @@ VERSIONED_FIELDS = {
|
|||||||
'automated_clean': versions.MINOR_47_NODE_AUTOMATED_CLEAN,
|
'automated_clean': versions.MINOR_47_NODE_AUTOMATED_CLEAN,
|
||||||
'protected': versions.MINOR_48_NODE_PROTECTED,
|
'protected': versions.MINOR_48_NODE_PROTECTED,
|
||||||
'protected_reason': versions.MINOR_48_NODE_PROTECTED,
|
'protected_reason': versions.MINOR_48_NODE_PROTECTED,
|
||||||
|
'conductor': versions.MINOR_49_CONDUCTORS,
|
||||||
}
|
}
|
||||||
|
|
||||||
for field in V31_FIELDS:
|
for field in V31_FIELDS:
|
||||||
@ -924,3 +925,25 @@ def get_request_return_fields(fields, detail, default_fields):
|
|||||||
if fields is None and not detail:
|
if fields is None and not detail:
|
||||||
return default_fields
|
return default_fields
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def allow_expose_conductors():
|
||||||
|
"""Check if accessing conductor endpoints is allowed.
|
||||||
|
|
||||||
|
Version 1.48 of the API exposed conductor endpoints and conductor field
|
||||||
|
for the node.
|
||||||
|
"""
|
||||||
|
return pecan.request.version.minor >= versions.MINOR_49_CONDUCTORS
|
||||||
|
|
||||||
|
|
||||||
|
def check_allow_filter_by_conductor(conductor):
|
||||||
|
"""Check if filtering nodes by conductor is allowed.
|
||||||
|
|
||||||
|
Version 1.48 of the API allows filtering nodes by conductor.
|
||||||
|
"""
|
||||||
|
if conductor is not None and not allow_expose_conductors():
|
||||||
|
raise exception.NotAcceptable(_(
|
||||||
|
"Request not acceptable. The minimal required API version "
|
||||||
|
"should be %(base)s.%(opr)s") %
|
||||||
|
{'base': versions.BASE_VERSION,
|
||||||
|
'opr': versions.MINOR_49_CONDUCTORS})
|
||||||
|
@ -136,6 +136,7 @@ MINOR_45_RESET_INTERFACES = 45
|
|||||||
MINOR_46_NODE_CONDUCTOR_GROUP = 46
|
MINOR_46_NODE_CONDUCTOR_GROUP = 46
|
||||||
MINOR_47_NODE_AUTOMATED_CLEAN = 47
|
MINOR_47_NODE_AUTOMATED_CLEAN = 47
|
||||||
MINOR_48_NODE_PROTECTED = 48
|
MINOR_48_NODE_PROTECTED = 48
|
||||||
|
MINOR_49_CONDUCTORS = 49
|
||||||
|
|
||||||
# When adding another version, update:
|
# When adding another version, update:
|
||||||
# - MINOR_MAX_VERSION
|
# - MINOR_MAX_VERSION
|
||||||
@ -143,7 +144,7 @@ MINOR_48_NODE_PROTECTED = 48
|
|||||||
# explanation of what changed in the new version
|
# explanation of what changed in the new version
|
||||||
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
|
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
|
||||||
|
|
||||||
MINOR_MAX_VERSION = MINOR_48_NODE_PROTECTED
|
MINOR_MAX_VERSION = MINOR_49_CONDUCTORS
|
||||||
|
|
||||||
# String representations of the minor and maximum versions
|
# String representations of the minor and maximum versions
|
||||||
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
||||||
|
@ -395,6 +395,15 @@ volume_policies = [
|
|||||||
'method': 'PATCH'}]),
|
'method': 'PATCH'}]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
conductor_policies = [
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'baremetal:conductor:get',
|
||||||
|
'rule:is_admin or rule:is_observer',
|
||||||
|
'Retrieve Conductor records',
|
||||||
|
[{'path': '/conductors', 'method': 'GET'},
|
||||||
|
{'path': '/conductors/{hostname}', 'method': 'GET'}]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def list_policies():
|
def list_policies():
|
||||||
policies = itertools.chain(
|
policies = itertools.chain(
|
||||||
@ -406,7 +415,8 @@ def list_policies():
|
|||||||
driver_policies,
|
driver_policies,
|
||||||
vendor_passthru_policies,
|
vendor_passthru_policies,
|
||||||
utility_policies,
|
utility_policies,
|
||||||
volume_policies
|
volume_policies,
|
||||||
|
conductor_policies
|
||||||
)
|
)
|
||||||
return policies
|
return policies
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@ RELEASE_MAPPING = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'master': {
|
'master': {
|
||||||
'api': '1.48',
|
'api': '1.49',
|
||||||
'rpc': '1.47',
|
'rpc': '1.47',
|
||||||
'objects': {
|
'objects': {
|
||||||
'Node': ['1.29', '1.28'],
|
'Node': ['1.29', '1.28'],
|
||||||
|
231
ironic/tests/unit/api/controllers/v1/test_conductor.py
Normal file
231
ironic/tests/unit/api/controllers/v1/test_conductor.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# 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.
|
||||||
|
"""
|
||||||
|
Tests for the API /conductors/ methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import mock
|
||||||
|
from oslo_config import cfg
|
||||||
|
from oslo_utils import timeutils
|
||||||
|
from oslo_utils import uuidutils
|
||||||
|
from six.moves import http_client
|
||||||
|
|
||||||
|
from ironic.api.controllers import base as api_base
|
||||||
|
from ironic.api.controllers import v1 as api_v1
|
||||||
|
from ironic.tests.unit.api import base as test_api_base
|
||||||
|
from ironic.tests.unit.objects import utils as obj_utils
|
||||||
|
|
||||||
|
|
||||||
|
class TestListConductors(test_api_base.BaseApiTest):
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertEqual([], data['conductors'])
|
||||||
|
|
||||||
|
def test_list(self):
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='why care')
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='why not')
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertEqual(2, len(data['conductors']))
|
||||||
|
for c in data['conductors']:
|
||||||
|
self.assertIn('hostname', c)
|
||||||
|
self.assertIn('conductor_group', c)
|
||||||
|
self.assertIn('alive', c)
|
||||||
|
self.assertNotIn('drivers', c)
|
||||||
|
self.assertEqual(data['conductors'][0]['hostname'], 'why care')
|
||||||
|
self.assertEqual(data['conductors'][1]['hostname'], 'why not')
|
||||||
|
|
||||||
|
def test_list_with_detail(self):
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='why care')
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='why not')
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors?detail=true',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertEqual(2, len(data['conductors']))
|
||||||
|
for c in data['conductors']:
|
||||||
|
self.assertIn('hostname', c)
|
||||||
|
self.assertIn('drivers', c)
|
||||||
|
self.assertIn('conductor_group', c)
|
||||||
|
self.assertIn('alive', c)
|
||||||
|
self.assertIn('drivers', c)
|
||||||
|
self.assertEqual(data['conductors'][0]['hostname'], 'why care')
|
||||||
|
self.assertEqual(data['conductors'][1]['hostname'], 'why not')
|
||||||
|
|
||||||
|
def test_list_with_invalid_api(self):
|
||||||
|
response = self.get_json(
|
||||||
|
'/conductors', headers={api_base.Version.string: '1.48'},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual(http_client.NOT_FOUND, response.status_int)
|
||||||
|
|
||||||
|
def test_get_one(self):
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='rocky.rocks')
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors/rocky.rocks',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertIn('hostname', data)
|
||||||
|
self.assertIn('drivers', data)
|
||||||
|
self.assertIn('conductor_group', data)
|
||||||
|
self.assertIn('alive', data)
|
||||||
|
self.assertIn('drivers', data)
|
||||||
|
self.assertEqual(data['hostname'], 'rocky.rocks')
|
||||||
|
self.assertTrue(data['alive'])
|
||||||
|
|
||||||
|
@mock.patch.object(timeutils, 'utcnow', autospec=True)
|
||||||
|
def test_get_one_conductor_offline(self, mock_utcnow):
|
||||||
|
self.config(heartbeat_timeout=10, group='conductor')
|
||||||
|
|
||||||
|
_time = datetime.datetime(2000, 1, 1, 0, 0)
|
||||||
|
mock_utcnow.return_value = _time
|
||||||
|
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='rocky.rocks')
|
||||||
|
|
||||||
|
mock_utcnow.return_value = _time + datetime.timedelta(seconds=30)
|
||||||
|
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors/rocky.rocks',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertIn('hostname', data)
|
||||||
|
self.assertIn('drivers', data)
|
||||||
|
self.assertIn('conductor_group', data)
|
||||||
|
self.assertIn('alive', data)
|
||||||
|
self.assertIn('drivers', data)
|
||||||
|
self.assertEqual(data['hostname'], 'rocky.rocks')
|
||||||
|
self.assertFalse(data['alive'])
|
||||||
|
|
||||||
|
def test_get_one_with_invalid_api(self):
|
||||||
|
response = self.get_json(
|
||||||
|
'/conductors/rocky.rocks',
|
||||||
|
headers={api_base.Version.string: '1.48'},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual(http_client.NOT_FOUND, response.status_int)
|
||||||
|
|
||||||
|
def test_get_one_custom_fields(self):
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='rocky.rocks')
|
||||||
|
fields = 'hostname,alive'
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors/rocky.rocks?fields=%s' % fields,
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertItemsEqual(['hostname', 'alive', 'links'], data)
|
||||||
|
|
||||||
|
def test_get_collection_custom_fields(self):
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='rocky.rocks')
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='stein.rocks')
|
||||||
|
fields = 'hostname,alive'
|
||||||
|
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors?fields=%s' % fields,
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
|
||||||
|
self.assertEqual(2, len(data['conductors']))
|
||||||
|
for c in data['conductors']:
|
||||||
|
self.assertItemsEqual(['hostname', 'alive', 'links'], c)
|
||||||
|
|
||||||
|
def test_get_custom_fields_invalid_fields(self):
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='rocky.rocks')
|
||||||
|
fields = 'hostname,spongebob'
|
||||||
|
response = self.get_json(
|
||||||
|
'/conductors/rocky.rocks?fields=%s' % fields,
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
|
||||||
|
self.assertEqual('application/json', response.content_type)
|
||||||
|
self.assertIn('spongebob', response.json['error_message'])
|
||||||
|
|
||||||
|
def _test_links(self, public_url=None):
|
||||||
|
cfg.CONF.set_override('public_endpoint', public_url, 'api')
|
||||||
|
obj_utils.create_test_conductor(self.context, hostname='rocky.rocks')
|
||||||
|
headers = {api_base.Version.string: str(api_v1.max_version())}
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors/rocky.rocks',
|
||||||
|
headers=headers)
|
||||||
|
self.assertIn('links', data)
|
||||||
|
self.assertEqual(2, len(data['links']))
|
||||||
|
self.assertIn('rocky.rocks', data['links'][0]['href'])
|
||||||
|
for l in data['links']:
|
||||||
|
bookmark = l['rel'] == 'bookmark'
|
||||||
|
self.assertTrue(self.validate_link(l['href'], bookmark=bookmark,
|
||||||
|
headers=headers))
|
||||||
|
|
||||||
|
if public_url is not None:
|
||||||
|
expected = [{'href': '%s/v1/conductors/rocky.rocks' % public_url,
|
||||||
|
'rel': 'self'},
|
||||||
|
{'href': '%s/conductors/rocky.rocks' % public_url,
|
||||||
|
'rel': 'bookmark'}]
|
||||||
|
for i in expected:
|
||||||
|
self.assertIn(i, data['links'])
|
||||||
|
|
||||||
|
def test_links(self):
|
||||||
|
self._test_links()
|
||||||
|
|
||||||
|
def test_links_public_url(self):
|
||||||
|
self._test_links(public_url='http://foo')
|
||||||
|
|
||||||
|
def test_collection_links(self):
|
||||||
|
conductors = []
|
||||||
|
for id in range(5):
|
||||||
|
hostname = uuidutils.generate_uuid()
|
||||||
|
conductor = obj_utils.create_test_conductor(self.context,
|
||||||
|
hostname=hostname)
|
||||||
|
conductors.append(conductor.hostname)
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors/?limit=3',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertEqual(3, len(data['conductors']))
|
||||||
|
|
||||||
|
next_marker = data['conductors'][-1]['hostname']
|
||||||
|
self.assertIn(next_marker, data['next'])
|
||||||
|
|
||||||
|
def test_collection_links_default_limit(self):
|
||||||
|
cfg.CONF.set_override('max_limit', 3, 'api')
|
||||||
|
conductors = []
|
||||||
|
for id in range(5):
|
||||||
|
hostname = uuidutils.generate_uuid()
|
||||||
|
conductor = obj_utils.create_test_conductor(self.context,
|
||||||
|
hostname=hostname)
|
||||||
|
conductors.append(conductor.hostname)
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertEqual(3, len(data['conductors']))
|
||||||
|
|
||||||
|
next_marker = data['conductors'][-1]['hostname']
|
||||||
|
self.assertIn(next_marker, data['next'])
|
||||||
|
|
||||||
|
def test_sort_key(self):
|
||||||
|
conductors = []
|
||||||
|
for id in range(5):
|
||||||
|
hostname = uuidutils.generate_uuid()
|
||||||
|
conductor = obj_utils.create_test_conductor(self.context,
|
||||||
|
hostname=hostname)
|
||||||
|
conductors.append(conductor.hostname)
|
||||||
|
data = self.get_json(
|
||||||
|
'/conductors?sort_key=hostname',
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
hosts = [n['hostname'] for n in data['conductors']]
|
||||||
|
self.assertEqual(sorted(conductors), hosts)
|
||||||
|
|
||||||
|
def test_sort_key_invalid(self):
|
||||||
|
invalid_keys_list = ['alive', 'drivers']
|
||||||
|
headers = {api_base.Version.string: str(api_v1.max_version())}
|
||||||
|
for invalid_key in invalid_keys_list:
|
||||||
|
response = self.get_json('/conductors?sort_key=%s' % invalid_key,
|
||||||
|
headers=headers,
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
|
||||||
|
self.assertEqual('application/json', response.content_type)
|
||||||
|
self.assertIn(invalid_key, response.json['error_message'])
|
@ -76,3 +76,6 @@ class TestExposedAPIMethodsCheckPolicy(test_base.TestCase):
|
|||||||
|
|
||||||
def test_ramdisk_api_policy(self):
|
def test_ramdisk_api_policy(self):
|
||||||
self._test('ironic.api.controllers.v1.ramdisk')
|
self._test('ironic.api.controllers.v1.ramdisk')
|
||||||
|
|
||||||
|
def test_conductor_api_policy(self):
|
||||||
|
self._test('ironic.api.controllers.v1.conductor')
|
||||||
|
@ -16,6 +16,7 @@ Tests for the API /nodes/ methods.
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import fixtures
|
||||||
import mock
|
import mock
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_utils import timeutils
|
from oslo_utils import timeutils
|
||||||
@ -63,6 +64,10 @@ class TestListNodes(test_api_base.BaseApiTest):
|
|||||||
self.mock_gtf = p.start()
|
self.mock_gtf = p.start()
|
||||||
self.mock_gtf.return_value = 'test-topic'
|
self.mock_gtf.return_value = 'test-topic'
|
||||||
self.addCleanup(p.stop)
|
self.addCleanup(p.stop)
|
||||||
|
self.mock_get_conductor_for = self.useFixture(
|
||||||
|
fixtures.MockPatchObject(rpcapi.ConductorAPI, 'get_conductor_for',
|
||||||
|
autospec=True)).mock
|
||||||
|
self.mock_get_conductor_for.return_value = 'fake.conductor'
|
||||||
|
|
||||||
def _create_association_test_nodes(self):
|
def _create_association_test_nodes(self):
|
||||||
# create some unassociated nodes
|
# create some unassociated nodes
|
||||||
@ -298,6 +303,10 @@ class TestListNodes(test_api_base.BaseApiTest):
|
|||||||
self._test_node_field_hidden_in_lower_version('protected_reason',
|
self._test_node_field_hidden_in_lower_version('protected_reason',
|
||||||
'1.47', '1.48')
|
'1.47', '1.48')
|
||||||
|
|
||||||
|
def test_node_conductor_hidden_in_lower_version(self):
|
||||||
|
self._test_node_field_hidden_in_lower_version('conductor',
|
||||||
|
'1.48', '1.49')
|
||||||
|
|
||||||
def test_node_protected(self):
|
def test_node_protected(self):
|
||||||
for value in (True, False):
|
for value in (True, False):
|
||||||
node = obj_utils.create_test_node(self.context, protected=value,
|
node = obj_utils.create_test_node(self.context, protected=value,
|
||||||
@ -489,6 +498,25 @@ class TestListNodes(test_api_base.BaseApiTest):
|
|||||||
headers={api_base.Version.string: '1.48'})
|
headers={api_base.Version.string: '1.48'})
|
||||||
self.assertIn('protected', response)
|
self.assertIn('protected', response)
|
||||||
|
|
||||||
|
def test_get_conductor_field_invalid_api_version(self):
|
||||||
|
node = obj_utils.create_test_node(self.context,
|
||||||
|
chassis_id=self.chassis.id)
|
||||||
|
fields = 'conductor'
|
||||||
|
response = self.get_json(
|
||||||
|
'/nodes/%s?fields=%s' % (node.uuid, fields),
|
||||||
|
headers={api_base.Version.string: '1.48'},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
|
||||||
|
|
||||||
|
def test_get_conductor_field(self):
|
||||||
|
node = obj_utils.create_test_node(self.context,
|
||||||
|
chassis_id=self.chassis.id)
|
||||||
|
fields = 'conductor'
|
||||||
|
response = self.get_json(
|
||||||
|
'/nodes/%s?fields=%s' % (node.uuid, fields),
|
||||||
|
headers={api_base.Version.string: '1.49'})
|
||||||
|
self.assertIn('conductor', response)
|
||||||
|
|
||||||
def test_detail(self):
|
def test_detail(self):
|
||||||
node = obj_utils.create_test_node(self.context,
|
node = obj_utils.create_test_node(self.context,
|
||||||
chassis_id=self.chassis.id)
|
chassis_id=self.chassis.id)
|
||||||
@ -1545,6 +1573,41 @@ class TestListNodes(test_api_base.BaseApiTest):
|
|||||||
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
|
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
|
||||||
self.assertTrue(response.json['error_message'])
|
self.assertTrue(response.json['error_message'])
|
||||||
|
|
||||||
|
def test_get_nodes_by_conductor_not_allowed(self):
|
||||||
|
response = self.get_json('/nodes?conductor=rocky.rocks',
|
||||||
|
headers={api_base.Version.string: "1.47"},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual('application/json', response.content_type)
|
||||||
|
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
|
||||||
|
self.assertTrue(response.json['error_message'])
|
||||||
|
|
||||||
|
def test_get_nodes_by_conductor(self):
|
||||||
|
node1 = obj_utils.create_test_node(self.context,
|
||||||
|
uuid=uuidutils.generate_uuid())
|
||||||
|
node2 = obj_utils.create_test_node(self.context,
|
||||||
|
uuid=uuidutils.generate_uuid())
|
||||||
|
|
||||||
|
response = self.get_json('/nodes?conductor=rocky.rocks',
|
||||||
|
headers={api_base.Version.string: "1.49"})
|
||||||
|
uuids = [n['uuid'] for n in response['nodes']]
|
||||||
|
self.assertFalse(uuids)
|
||||||
|
|
||||||
|
response = self.get_json('/nodes?conductor=fake.conductor',
|
||||||
|
headers={api_base.Version.string: "1.49"})
|
||||||
|
uuids = [n['uuid'] for n in response['nodes']]
|
||||||
|
self.assertEqual(2, len(uuids))
|
||||||
|
self.assertIn(node1.uuid, uuids)
|
||||||
|
self.assertIn(node2.uuid, uuids)
|
||||||
|
|
||||||
|
self.mock_get_conductor_for.side_effect = ['rocky.rocks',
|
||||||
|
'fake.conductor']
|
||||||
|
response = self.get_json('/nodes?conductor=fake.conductor',
|
||||||
|
headers={api_base.Version.string: "1.49"})
|
||||||
|
uuids = [n['uuid'] for n in response['nodes']]
|
||||||
|
self.assertEqual(1, len(uuids))
|
||||||
|
self.assertNotIn(node1.uuid, uuids)
|
||||||
|
self.assertIn(node2.uuid, uuids)
|
||||||
|
|
||||||
def test_get_console_information(self):
|
def test_get_console_information(self):
|
||||||
node = obj_utils.create_test_node(self.context)
|
node = obj_utils.create_test_node(self.context)
|
||||||
expected_console_info = {'test': 'test-data'}
|
expected_console_info = {'test': 'test-data'}
|
||||||
@ -2739,6 +2802,19 @@ class TestPatch(test_api_base.BaseApiTest):
|
|||||||
self.assertEqual('application/json', response.content_type)
|
self.assertEqual('application/json', response.content_type)
|
||||||
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
|
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
|
||||||
|
|
||||||
|
def test_patch_conductor_forbidden(self):
|
||||||
|
node = obj_utils.create_test_node(self.context,
|
||||||
|
uuid=uuidutils.generate_uuid())
|
||||||
|
response = self.patch_json('/nodes/%s' % node.uuid,
|
||||||
|
[{'path': '/conductor',
|
||||||
|
'op': 'replace',
|
||||||
|
'value': 'why care'}],
|
||||||
|
headers={api_base.Version.string: "1.49"},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual('application/json', response.content_type)
|
||||||
|
self.assertEqual(http_client.BAD_REQUEST, response.status_code)
|
||||||
|
self.assertTrue(response.json['error_message'])
|
||||||
|
|
||||||
|
|
||||||
def _create_node_locally(node):
|
def _create_node_locally(node):
|
||||||
driver_factory.check_and_update_node_interfaces(node)
|
driver_factory.check_and_update_node_interfaces(node)
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
Tests for the API /lookup/ methods.
|
Tests for the API /lookup/ methods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import fixtures
|
||||||
import mock
|
import mock
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
from oslo_utils import uuidutils
|
from oslo_utils import uuidutils
|
||||||
@ -43,6 +44,10 @@ class TestLookup(test_api_base.BaseApiTest):
|
|||||||
uuid=uuidutils.generate_uuid(),
|
uuid=uuidutils.generate_uuid(),
|
||||||
provision_state='available')
|
provision_state='available')
|
||||||
CONF.set_override('agent_backend', 'statsd', 'metrics')
|
CONF.set_override('agent_backend', 'statsd', 'metrics')
|
||||||
|
self.mock_get_conductor_for = self.useFixture(
|
||||||
|
fixtures.MockPatchObject(rpcapi.ConductorAPI, 'get_conductor_for',
|
||||||
|
autospec=True)).mock
|
||||||
|
self.mock_get_conductor_for.return_value = 'fake.conductor'
|
||||||
|
|
||||||
def _check_config(self, data):
|
def _check_config(self, data):
|
||||||
expected_metrics = {
|
expected_metrics = {
|
||||||
|
@ -217,6 +217,7 @@ def get_test_node(**kw):
|
|||||||
'automated_clean': kw.get('automated_clean', None),
|
'automated_clean': kw.get('automated_clean', None),
|
||||||
'protected': kw.get('protected', False),
|
'protected': kw.get('protected', False),
|
||||||
'protected_reason': kw.get('protected_reason', None),
|
'protected_reason': kw.get('protected_reason', None),
|
||||||
|
'conductor': kw.get('conductor'),
|
||||||
}
|
}
|
||||||
|
|
||||||
for iface in drivers_base.ALL_INTERFACES:
|
for iface in drivers_base.ALL_INTERFACES:
|
||||||
|
@ -255,6 +255,16 @@ def create_test_bios_setting(ctxt, **kw):
|
|||||||
return bios_setting
|
return bios_setting
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_conductor(ctxt, **kw):
|
||||||
|
"""Register and return a test conductor object."""
|
||||||
|
args = db_utils.get_test_conductor(**kw)
|
||||||
|
conductor = objects.Conductor.register(ctxt, args['hostname'],
|
||||||
|
args['drivers'],
|
||||||
|
args['conductor_group'],
|
||||||
|
update_existing=True)
|
||||||
|
return conductor
|
||||||
|
|
||||||
|
|
||||||
def get_payloads_with_schemas(from_module):
|
def get_payloads_with_schemas(from_module):
|
||||||
"""Get the Payload classes with SCHEMAs defined.
|
"""Get the Payload classes with SCHEMAs defined.
|
||||||
|
|
||||||
|
18
releasenotes/notes/expose-conductor-d13c9c4ef9d9de86.yaml
Normal file
18
releasenotes/notes/expose-conductor-d13c9c4ef9d9de86.yaml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Adds support to retrieve the information of conductors known
|
||||||
|
by ironic:
|
||||||
|
|
||||||
|
* a new endpoint ``GET /v1/conductors`` for listing conductor resources.
|
||||||
|
* a new endpoint ``GET /v1/conductors/{hostname}`` for showing a
|
||||||
|
conductor resource.
|
||||||
|
|
||||||
|
Adds a read-only ``conductor`` field to the Node, which represents the
|
||||||
|
conductor currently servicing a node, and can be retrieved from following
|
||||||
|
node endpoints:
|
||||||
|
|
||||||
|
* ``GET /v1/nodes?detail=true`` or ``GET /v1/nodes/detail``
|
||||||
|
* ``GET /v1/nodes/<node_ident>``
|
||||||
|
* ``POST /v1/nodes``
|
||||||
|
* ``PATCH /v1/nodes/<node_ident>``
|
Loading…
Reference in New Issue
Block a user