diff options
author | Kaifeng Wang <kaifeng.w@gmail.com> | 2018-11-23 15:33:33 +0800 |
---|---|---|
committer | Kaifeng Wang <kaifeng.w@gmail.com> | 2018-12-04 09:13:24 +0800 |
commit | 7c7744dfb3ef617537f584478d7fd2cffb2481f4 (patch) | |
tree | 5abb25798c90bb5386ba8349d26269d5e9075d76 /ironic/api | |
parent | e2a768f0cd2abf6f2ac456949a8c46628b27b5ef (diff) | |
download | ironic-7c7744dfb3ef617537f584478d7fd2cffb2481f4.tar.gz |
Expose conductors: api
This patch implements API part to the feature of exposing conductors
information.
A new API object Conductor is added to provide endpoints below:
* GET /v1/conductors for listing conductor resources
* GET /v1/conductors/{hostname} for showing a conductor
V1 endpoint discovery and default policy are updated.
A conductor field is added to Node API object, which returns in
/v1/nodes* related endpoints.
Considering patch size and microversion conflicting with other api
patches, api-ref would go in another patch if no strong opinions.
Story: 1724474
Task: 28064
Change-Id: Iec6aaabc46442a60e2d27e02c21e67234b84d77b
Diffstat (limited to 'ironic/api')
-rw-r--r-- | ironic/api/controllers/v1/__init__.py | 14 | ||||
-rw-r--r-- | ironic/api/controllers/v1/collection.py | 6 | ||||
-rw-r--r-- | ironic/api/controllers/v1/conductor.py | 251 | ||||
-rw-r--r-- | ironic/api/controllers/v1/node.py | 112 | ||||
-rw-r--r-- | ironic/api/controllers/v1/utils.py | 23 | ||||
-rw-r--r-- | ironic/api/controllers/v1/versions.py | 3 |
6 files changed, 376 insertions, 33 deletions
diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py index 4af57f94b..074f6d84b 100644 --- a/ironic/api/controllers/v1/__init__.py +++ b/ironic/api/controllers/v1/__init__.py @@ -26,6 +26,7 @@ from wsme import types as wtypes from ironic.api.controllers import base from ironic.api.controllers import link 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 node from ironic.api.controllers.v1 import port @@ -100,6 +101,9 @@ class V1(base.APIBase): heartbeat = [link.Link] """Links to the heartbeat resource""" + conductors = [link.Link] + """Links to the conductors resource""" + version = version.Version """Version discovery information.""" @@ -178,6 +182,15 @@ class V1(base.APIBase): 'heartbeat', '', 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() return v1 @@ -193,6 +206,7 @@ class Controller(rest.RestController): volume = volume.VolumeController() lookup = ramdisk.LookupController() heartbeat = ramdisk.HeartbeatController() + conductors = conductor.ConductorsController() @expose.expose(V1) def get(self): diff --git a/ironic/api/controllers/v1/collection.py b/ironic/api/controllers/v1/collection.py index 9f82bd1bf..032819441 100644 --- a/ironic/api/controllers/v1/collection.py +++ b/ironic/api/controllers/v1/collection.py @@ -29,6 +29,10 @@ class Collection(base.APIBase): def collection(self): return getattr(self, self._type) + @classmethod + def get_key_field(cls): + return 'uuid' + def has_next(self, limit): """Return whether collection has more items.""" 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]) next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % { '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, resource_url, next_args).href diff --git a/ironic/api/controllers/v1/conductor.py b/ironic/api/controllers/v1/conductor.py new file mode 100644 index 000000000..b405e3e10 --- /dev/null +++ b/ironic/api/controllers/v1/conductor.py @@ -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) diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 933a33122..41c890e41 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -1072,6 +1072,9 @@ class Node(base.APIBase): protected_reason = wsme.wsattr(wtypes.text) """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 # 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 # because it's an API-only attribute. fields.append('chassis_uuid') + # NOTE(kaifeng) conductor is not part of objects.Node.fields too. + fields.append('conductor') for k in fields: # Add fields we expose. if hasattr(self, k): @@ -1149,6 +1154,18 @@ class Node(base.APIBase): def convert_with_links(cls, rpc_node, fields=None, sanitize=True): 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: api_utils.check_for_invalid_fields(fields, node.as_dict()) @@ -1286,7 +1303,7 @@ class NodePatchType(types.JsonPatchType): '/inspection_started_at', '/clean_step', '/deploy_step', '/raid_config', '/target_raid_config', - '/fault'] + '/fault', '/conductor'] class NodeCollection(collection.Collection): @@ -1564,12 +1581,43 @@ class NodesController(rest.RestController): if subcontroller: return subcontroller(node_ident=ident), remainder[1:] + def _filter_by_conductor(self, nodes, conductor): + filtered_nodes = [] + for n in nodes: + host = pecan.request.rpcapi.get_conductor_for(n) + if host == conductor: + filtered_nodes.append(n) + return filtered_nodes + + def _create_node_filters(self, chassis_uuid=None, associated=None, + maintenance=None, provision_state=None, + driver=None, resource_class=None, fault=None, + conductor_group=None): + filters = {} + if chassis_uuid: + filters['chassis_uuid'] = chassis_uuid + if associated is not None: + filters['associated'] = associated + if maintenance is not None: + filters['maintenance'] = maintenance + if provision_state: + filters['provision_state'] = provision_state + if driver: + filters['driver'] = driver + if resource_class is not None: + filters['resource_class'] = resource_class + if fault is not None: + filters['fault'] = fault + if conductor_group is not None: + 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): + detail=None, conductor=None): if self.from_chassis and not chassis_uuid: raise exception.MissingParameterValue( _("Chassis id not specified.")) @@ -1577,16 +1625,16 @@ class NodesController(rest.RestController): limit = api_utils.validate_limit(limit) sort_dir = api_utils.validate_sort_dir(sort_dir) - marker_obj = 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}) + marker_obj = None + if marker: + marker_obj = objects.Node.get_by_uuid(pecan.request.context, + marker) + # The query parameters for the 'next' URL parameters = {} @@ -1602,28 +1650,18 @@ class NodesController(rest.RestController): # be generated, which we don't want. limit = 0 else: - filters = {} - if chassis_uuid: - filters['chassis_uuid'] = chassis_uuid - if associated is not None: - filters['associated'] = associated - if maintenance is not None: - filters['maintenance'] = maintenance - if provision_state: - filters['provision_state'] = provision_state - if driver: - filters['driver'] = driver - if resource_class is not None: - filters['resource_class'] = resource_class - if fault is not None: - filters['fault'] = fault - if conductor_group is not None: - filters['conductor_group'] = conductor_group - + 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, sort_key=sort_key, sort_dir=sort_dir, 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} if associated: parameters['associated'] = associated @@ -1726,12 +1764,12 @@ class NodesController(rest.RestController): @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, types.boolean, wtypes.text, types.uuid, int, 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, maintenance=None, provision_state=None, marker=None, limit=None, sort_key='id', sort_dir='asc', driver=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. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1759,6 +1797,8 @@ class NodesController(rest.RestController): that resource_class. :param conductor_group: Optional string value to get only nodes with 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 of the resource to be returned. :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_filter_by_fault(fault) 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, _DEFAULT_RETURN_FIELDS) @@ -1786,17 +1827,19 @@ class NodesController(rest.RestController): resource_class=resource_class, fields=fields, fault=fault, conductor_group=conductor_group, - detail=detail) + detail=detail, + conductor=conductor) @METRICS.timer('NodesController.detail') @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, types.boolean, wtypes.text, types.uuid, int, 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, maintenance=None, provision_state=None, marker=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. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1840,6 +1883,8 @@ class NodesController(rest.RestController): if parent != "nodes": raise exception.HTTPNotFound() + api_utils.check_allow_filter_by_conductor(conductor) + resource_url = '/'.join(['nodes', 'detail']) return self._get_nodes_collection(chassis_uuid, instance_uuid, associated, maintenance, @@ -1849,7 +1894,8 @@ class NodesController(rest.RestController): resource_class=resource_class, resource_url=resource_url, fault=fault, - conductor_group=conductor_group) + conductor_group=conductor_group, + conductor=conductor) @METRICS.timer('NodesController.validate') @expose.expose(wtypes.text, types.uuid_or_name, types.uuid) @@ -1913,6 +1959,10 @@ class NodesController(rest.RestController): if self.from_chassis: 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) if node.traits is not wtypes.Unset: diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 73c84a246..bed014844 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -378,6 +378,7 @@ VERSIONED_FIELDS = { 'automated_clean': versions.MINOR_47_NODE_AUTOMATED_CLEAN, 'protected': versions.MINOR_48_NODE_PROTECTED, 'protected_reason': versions.MINOR_48_NODE_PROTECTED, + 'conductor': versions.MINOR_49_CONDUCTORS, } 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: return default_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}) diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 7b6c61c04..826df55d1 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -136,6 +136,7 @@ MINOR_45_RESET_INTERFACES = 45 MINOR_46_NODE_CONDUCTOR_GROUP = 46 MINOR_47_NODE_AUTOMATED_CLEAN = 47 MINOR_48_NODE_PROTECTED = 48 +MINOR_49_CONDUCTORS = 49 # When adding another version, update: # - MINOR_MAX_VERSION @@ -143,7 +144,7 @@ MINOR_48_NODE_PROTECTED = 48 # explanation of what changed in the new version # - 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 _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) |