summaryrefslogtreecommitdiff
path: root/ironic/api
diff options
context:
space:
mode:
authorKaifeng Wang <kaifeng.w@gmail.com>2018-11-23 15:33:33 +0800
committerKaifeng Wang <kaifeng.w@gmail.com>2018-12-04 09:13:24 +0800
commit7c7744dfb3ef617537f584478d7fd2cffb2481f4 (patch)
tree5abb25798c90bb5386ba8349d26269d5e9075d76 /ironic/api
parente2a768f0cd2abf6f2ac456949a8c46628b27b5ef (diff)
downloadironic-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__.py14
-rw-r--r--ironic/api/controllers/v1/collection.py6
-rw-r--r--ironic/api/controllers/v1/conductor.py251
-rw-r--r--ironic/api/controllers/v1/node.py112
-rw-r--r--ironic/api/controllers/v1/utils.py23
-rw-r--r--ironic/api/controllers/v1/versions.py3
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)