Merge "Add RPC and object version pinning"

This commit is contained in:
Jenkins 2017-03-29 17:33:17 +00:00 committed by Gerrit Code Review
commit a84c2e0d8e
9 changed files with 372 additions and 2 deletions

View File

@ -47,11 +47,24 @@ Things to do before releasing
TEMPEST_BAREMETAL_MAX_MICROVERSION in devstack/lib/ironic to make sure that TEMPEST_BAREMETAL_MAX_MICROVERSION in devstack/lib/ironic to make sure that
unsupported API tempest tests are skipped on stable branches. unsupported API tempest tests are skipped on stable branches.
* To support rolling upgrades, add this new release version (and release name
if it is a named release) into ironic/common/release_mappings.py:
* in RELEASE_MAPPING, make a copy of the 'master' entry, and rename the first
'master' entry to the new semver release version.
* If this is a named release, add a RELEASE_MAPPING entry for the named
release. Its value should be the same as that of the latest semver one
(that you just added above).
* Regenerate the sample config file, so that the choices for the
``[DEFAULT]/pin_release_version`` configuration are accurate.
.. _`standards`: http://docs.openstack.org/developer/ironic/dev/faq.html#know-if-a-release-note-is-needed-for-my-change .. _`standards`: http://docs.openstack.org/developer/ironic/dev/faq.html#know-if-a-release-note-is-needed-for-my-change
Things to do after releasing Things to do after releasing
============================ ============================
When a release is done that results in a stable branch
------------------------------------------------------
When a release is done that results in a stable branch for the project, the When a release is done that results in a stable branch for the project, the
release automation will push a number of changes that need to be approved. release automation will push a number of changes that need to be approved.
@ -82,8 +95,21 @@ Additionally, changes need to be made on master to:
and `pbr documentation and `pbr documentation
<http://docs.openstack.org/developer/pbr/#version>`_ for details. <http://docs.openstack.org/developer/pbr/#version>`_ for details.
For all releases
----------------
For all releases, whether or not it results in a stable branch: For all releases, whether or not it results in a stable branch:
* update the specs repo to mark any specs completed in the release as * update the specs repo to mark any specs completed in the release as
implemented. implemented.
* remove any -2s on patches that were blocked until after the release. * remove any -2s on patches that were blocked until after the release.
* to support rolling upgrades, make these changes in
ironic/common/release_mappings.py:
* if the release was a named release, delete any entries from
RELEASE_MAPPING associated with the oldest named release. Since we
support upgrades between adjacent named releases, the master branch will
only support upgrades from the most recent named release to master.
* regenerate the sample config file, so that the choices for the
``[DEFAULT]/pin_release_version`` configuration are accurate.

View File

@ -354,6 +354,15 @@
# value) # value)
#host = localhost #host = localhost
# Used for rolling upgrades. Setting this option downgrades
# the internal ironic RPC communication to the specified
# version to enable communication with older services. When
# doing a rolling upgrade from version X to version Y, set
# this to X. Defaults to using the newest possible RPC
# behavior. (string value)
# Allowed values: ocata, 7.0
#pin_release_version = <None>
# Path to the rootwrap configuration file to use for running # Path to the rootwrap configuration file to use for running
# commands as root. (string value) # commands as root. (string value)
#rootwrap_config = /etc/ironic/rootwrap.conf #rootwrap_config = /etc/ironic/rootwrap.conf

View File

@ -0,0 +1,86 @@
# Copyright 2016 Intel Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# NOTE(xek): This decides the version cap of RPC messages sent to conductor
# and objects during rolling upgrades, when [DEFAULT]/pin_release_version
# configuration is set.
#
# Remember to add a new entry for the new version that is shipping in a new
# release.
#
# We support a rolling upgrade between adjacent named releases, as well as
# between a release and master, so old, unsupported releases can be removed,
# together with the supporting code, which is typically found in an object's
# make_compatible methods and RPC client code.
# NOTE(xek): The format of this dict is:
# { '<release version>': {
# 'rpc': '<RPC API version>',
# 'objects': {
# '<object class name>': '<object version>',
# }
# },
# }
# The list should contain all objects which are persisted in the database and
# sent over RPC. Notifications/Payloads are not being included here since we
# don't need to pin them during rolling upgrades.
#
# There should always be a 'master' entry that reflects the objects in the
# master branch.
#
# Just before doing a release, copy the 'master' entry, and rename the first
# 'master' entry to the (semver) version being released.
#
# Just after doing a named release, delete any entries associated with the
# oldest named release.
RELEASE_MAPPING = {
'7.0': {
'rpc': '1.40',
'objects': {
'Node': '1.21',
'Conductor': '1.2',
'Chassis': '1.3',
'Port': '1.6',
'Portgroup': '1.3',
'VolumeConnector': '1.0',
'VolumeTarget': '1.0',
}
},
'master': {
'rpc': '1.40',
'objects': {
'Node': '1.21',
'Conductor': '1.2',
'Chassis': '1.3',
'Port': '1.6',
'Portgroup': '1.3',
'VolumeConnector': '1.0',
'VolumeTarget': '1.0',
}
},
}
# NOTE(xek): Assign each named release to the appropriate semver.
#
# Just before we do a new named release, add a mapping for the new
# named release.
#
# Just after we do a new named release, delete the oldest named
# release (that we are no longer supporting for a rolling upgrade).
#
# There should be at most two named mappings here.
RELEASE_MAPPING['ocata'] = RELEASE_MAPPING['7.0']
# List of available versions with named versions first; 'master' is excluded.
RELEASE_VERSIONS = sorted(set(RELEASE_MAPPING) - {'master'}, reverse=True)

View File

@ -25,6 +25,7 @@ import oslo_messaging as messaging
from ironic.common import exception from ironic.common import exception
from ironic.common import hash_ring from ironic.common import hash_ring
from ironic.common.i18n import _ from ironic.common.i18n import _
from ironic.common import release_mappings as versions
from ironic.common import rpc from ironic.common import rpc
from ironic.conductor import manager from ironic.conductor import manager
from ironic.conf import CONF from ironic.conf import CONF
@ -103,8 +104,10 @@ class ConductorAPI(object):
target = messaging.Target(topic=self.topic, target = messaging.Target(topic=self.topic,
version='1.0') version='1.0')
serializer = objects_base.IronicObjectSerializer() serializer = objects_base.IronicObjectSerializer()
self.client = rpc.get_client(target, release_ver = versions.RELEASE_MAPPING.get(CONF.pin_release_version)
version_cap=self.RPC_API_VERSION, version_cap = (release_ver['rpc'] if release_ver
else self.RPC_API_VERSION)
self.client = rpc.get_client(target, version_cap=version_cap,
serializer=serializer) serializer=serializer)
# NOTE(deva): this is going to be buggy # NOTE(deva): this is going to be buggy
self.ring_manager = hash_ring.HashRingManager() self.ring_manager = hash_ring.HashRingManager()

View File

@ -25,6 +25,7 @@ from oslo_config import cfg
from oslo_utils import netutils from oslo_utils import netutils
from ironic.common.i18n import _ from ironic.common.i18n import _
from ironic.common import release_mappings as versions
_ENABLED_IFACE_HELP = _('Specify the list of {0} interfaces to load during ' _ENABLED_IFACE_HELP = _('Specify the list of {0} interfaces to load during '
@ -261,6 +262,15 @@ service_opts = [
'However, the node name must be valid within ' 'However, the node name must be valid within '
'an AMQP key, and if using ZeroMQ, a valid ' 'an AMQP key, and if using ZeroMQ, a valid '
'hostname, FQDN, or IP address.')), 'hostname, FQDN, or IP address.')),
cfg.StrOpt('pin_release_version',
choices=versions.RELEASE_VERSIONS,
# TODO(xek): mutable=True,
help=_('Used for rolling upgrades. Setting this option '
'downgrades the internal ironic RPC communication to '
'the specified version to enable communication with '
'older services. When doing a rolling upgrade from '
'version X to version Y, set this to X. Defaults to '
'using the newest possible RPC behavior.')),
] ]
utils_opts = [ utils_opts = [

View File

@ -14,12 +14,17 @@
"""Ironic common internal object model""" """Ironic common internal object model"""
from oslo_log import log
from oslo_utils import versionutils from oslo_utils import versionutils
from oslo_versionedobjects import base as object_base from oslo_versionedobjects import base as object_base
from ironic.common import release_mappings as versions
from ironic.conf import CONF
from ironic import objects from ironic import objects
from ironic.objects import fields as object_fields from ironic.objects import fields as object_fields
LOG = log.getLogger(__name__)
class IronicObjectRegistry(object_base.VersionedObjectRegistry): class IronicObjectRegistry(object_base.VersionedObjectRegistry):
def registration_hook(self, cls, index): def registration_hook(self, cls, index):
@ -103,7 +108,35 @@ class IronicObject(object_base.VersionedObject):
return [cls._from_db_object(cls(context), db_obj) return [cls._from_db_object(cls(context), db_obj)
for db_obj in db_objects] for db_obj in db_objects]
def _get_target_version(self):
"""Returns the target version for this object.
If pinned, returns the version of this object corresponding to
the pin. Otherwise, returns this (latest) version of the object.
"""
pinned_version = None
pin = CONF.pin_release_version
if pin:
version_manifest = versions.RELEASE_MAPPING[pin]['objects']
pinned_version = version_manifest.get(self.obj_name())
return pinned_version or self.__class__.VERSION
class IronicObjectSerializer(object_base.VersionedObjectSerializer): class IronicObjectSerializer(object_base.VersionedObjectSerializer):
# Base class to use for object hydration # Base class to use for object hydration
OBJ_BASE_CLASS = IronicObject OBJ_BASE_CLASS = IronicObject
def serialize_entity(self, context, entity):
if isinstance(entity, (tuple, list, set, dict)):
entity = self._process_iterable(context, self.serialize_entity,
entity)
elif (hasattr(entity, 'obj_to_primitive')
and callable(entity.obj_to_primitive)):
target_version = entity._get_target_version()
# NOTE(xek): If the version is pinned, target_version is an older
# object version and entity's obj_make_compatible method is called
# to backport the object before serialization.
entity = entity.obj_to_primitive(target_version=target_version)
elif not isinstance(entity, (int, str, bool, float, type)) and entity:
LOG.warning("Entity %s was not serialized.", str(entity))
return entity

View File

@ -0,0 +1,95 @@
# Copyright 2016 Intel Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from oslo_utils import versionutils
import six
from ironic.common.release_mappings import RELEASE_MAPPING
from ironic.conductor import rpcapi
from ironic.db.sqlalchemy import models
from ironic.objects import base as obj_base
from ironic.tests import base
from ironic import version
def _check_versions_compatibility(conf_version, actual_version):
"""Checks the configured version against the actual version.
Returns True if the configured version is <= the actual version;
otherwise returns False.
param conf_version: configured version, a string with dots
param actual_version: actual version, a string with dots
"""
conf_cap = versionutils.convert_version_to_tuple(conf_version)
actual_cap = versionutils.convert_version_to_tuple(actual_version)
return conf_cap <= actual_cap
class ReleaseMappingsTestCase(base.TestCase):
"""Tests whether the dict RELEASE_MAPPING is correct, valid and consistent.
"""
def test_structure(self):
for value in RELEASE_MAPPING.values():
self.assertTrue(isinstance(value, dict))
self.assertEqual({'rpc', 'objects'}, set(value))
self.assertTrue(isinstance(value['rpc'], six.string_types))
self.assertTrue(isinstance(value['objects'], dict))
for obj_value in value['objects'].values():
self.assertTrue(isinstance(obj_value, six.string_types))
tuple_ver = versionutils.convert_version_to_tuple(obj_value)
self.assertEqual(2, len(tuple_ver))
def test_object_names_are_registered(self):
registered_objects = set(obj_base.IronicObjectRegistry.obj_classes())
for mapping in RELEASE_MAPPING.values():
objects = set(mapping['objects'])
self.assertTrue(objects.issubset(registered_objects))
def test_contains_current_release_entry(self):
version_info = str(version.version_info)
# NOTE(sborkows): We only need first two values from version (like 5.1)
# and assume version_info is of the form 'x.y.z'.
current_release = version_info[:version_info.rfind('.')]
self.assertIn(current_release, RELEASE_MAPPING)
def test_current_rpc_version(self):
self.assertEqual(rpcapi.ConductorAPI.RPC_API_VERSION,
RELEASE_MAPPING['master']['rpc'])
def test_current_object_versions(self):
registered_objects = obj_base.IronicObjectRegistry.obj_classes()
for obj, objver in RELEASE_MAPPING['master']['objects'].items():
self.assertEqual(registered_objects[obj][0].VERSION, objver)
def test_contains_all_db_objects(self):
self.assertIn('master', RELEASE_MAPPING)
model_names = set((s.__name__ for s in models.Base.__subclasses__()))
exceptions = set(['NodeTag', 'ConductorHardwareInterfaces'])
# NOTE(xek): As a rule, all models which can be changed between
# releases or are sent through RPC should have their counterpart
# versioned objects. This means all, but very simple models.
model_names -= exceptions
object_names = set(RELEASE_MAPPING['master']['objects'])
self.assertEqual(model_names, object_names)
def test_rpc_and_objects_versions_supported(self):
registered_objects = obj_base.IronicObjectRegistry.obj_classes()
for versions in RELEASE_MAPPING.values():
self.assertTrue(_check_versions_compatibility(
versions['rpc'], rpcapi.ConductorAPI.RPC_API_VERSION))
for obj_name, obj_ver in versions['objects'].items():
self.assertTrue(_check_versions_compatibility(
obj_ver, registered_objects[obj_name][0].VERSION))

View File

@ -27,6 +27,7 @@ from oslo_messaging import _utils as messaging_utils
from ironic.common import boot_devices from ironic.common import boot_devices
from ironic.common import exception from ironic.common import exception
from ironic.common import release_mappings
from ironic.common import states from ironic.common import states
from ironic.conductor import manager as conductor_manager from ironic.conductor import manager as conductor_manager
from ironic.conductor import rpcapi as conductor_rpcapi from ironic.conductor import rpcapi as conductor_rpcapi
@ -45,6 +46,22 @@ class ConductorRPCAPITestCase(tests_base.TestCase):
conductor_manager.ConductorManager.RPC_API_VERSION, conductor_manager.ConductorManager.RPC_API_VERSION,
conductor_rpcapi.ConductorAPI.RPC_API_VERSION) conductor_rpcapi.ConductorAPI.RPC_API_VERSION)
@mock.patch('ironic.common.rpc.get_client')
def test_version_cap(self, mock_get_client):
conductor_rpcapi.ConductorAPI()
self.assertEqual(conductor_rpcapi.ConductorAPI.RPC_API_VERSION,
mock_get_client.call_args[1]['version_cap'])
@mock.patch('ironic.common.release_mappings.RELEASE_MAPPING')
@mock.patch('ironic.common.rpc.get_client')
def test_version_capped(self, mock_get_client, mock_release_mapping):
CONF.set_override('pin_release_version',
release_mappings.RELEASE_VERSIONS[0],
enforce_type=True)
mock_release_mapping.get.return_value = {'rpc': '3'}
conductor_rpcapi.ConductorAPI()
self.assertEqual('3', mock_get_client.call_args[1]['version_cap'])
class RPCAPITestCase(base.DbTestCase): class RPCAPITestCase(base.DbTestCase):

View File

@ -23,6 +23,8 @@ from oslo_versionedobjects import fixture as object_fixture
import six import six
from ironic.common import context from ironic.common import context
from ironic.common import release_mappings
from ironic.conf import CONF
from ironic.objects import base from ironic.objects import base
from ironic.objects import fields from ironic.objects import fields
from ironic.tests import base as test_base from ironic.tests import base as test_base
@ -37,6 +39,11 @@ class MyObj(base.IronicObject, object_base.VersionedObjectDictCompat):
'missing': fields.StringField(), 'missing': fields.StringField(),
} }
def obj_make_compatible(self, primitive, target_version):
super(MyObj, self).obj_make_compatible(primitive, target_version)
if target_version == '1.4' and 'missing' in primitive:
del primitive['missing']
def obj_load_attr(self, attrname): def obj_load_attr(self, attrname):
setattr(self, attrname, 'loaded!') setattr(self, attrname, 'loaded!')
@ -518,6 +525,90 @@ class TestObjectSerializer(test_base.TestCase):
"Test object with unsupported (newer) version and revision" "Test object with unsupported (newer) version and revision"
self._test_deserialize_entity_newer('1.7', '1.6.1', my_version='1.6.1') self._test_deserialize_entity_newer('1.7', '1.6.1', my_version='1.6.1')
@mock.patch.object(MyObj, 'obj_make_compatible')
def test_serialize_entity_no_backport(self, make_compatible_mock):
"""Test single element serializer with no backport."""
serializer = base.IronicObjectSerializer()
obj = MyObj(self.context)
obj.foo = 1
obj.bar = 'text'
obj.missing = 'textt'
primitive = serializer.serialize_entity(self.context, obj)
self.assertEqual('1.5', primitive['ironic_object.version'])
data = primitive['ironic_object.data']
self.assertEqual(1, data['foo'])
self.assertEqual('text', data['bar'])
self.assertEqual('textt', data['missing'])
changes = primitive['ironic_object.changes']
self.assertEqual(set(['foo', 'bar', 'missing']), set(changes))
make_compatible_mock.assert_not_called()
@mock.patch('ironic.common.release_mappings.RELEASE_MAPPING')
def test_serialize_entity_backport(self, mock_release_mapping):
"""Test single element serializer with backport."""
CONF.set_override('pin_release_version',
release_mappings.RELEASE_VERSIONS[-1],
enforce_type=True)
mock_release_mapping.__getitem__.return_value = {
'objects': {
'MyObj': '1.4',
}
}
serializer = base.IronicObjectSerializer()
obj = MyObj(self.context)
obj.foo = 1
obj.bar = 'text'
obj.missing = 'textt'
primitive = serializer.serialize_entity(self.context, obj)
self.assertEqual('1.4', primitive['ironic_object.version'])
data = primitive['ironic_object.data']
self.assertEqual(1, data['foo'])
self.assertEqual('text', data['bar'])
self.assertNotIn('missing', data)
changes = primitive['ironic_object.changes']
self.assertEqual(set(['foo', 'bar']), set(changes))
@mock.patch('ironic.common.release_mappings.RELEASE_MAPPING')
def test_serialize_entity_invalid_pin(self, mock_release_mapping):
CONF.set_override('pin_release_version',
release_mappings.RELEASE_VERSIONS[-1],
enforce_type=True)
mock_release_mapping.__getitem__.return_value = {
'objects': {
'MyObj': '1.6',
}
}
serializer = base.IronicObjectSerializer()
obj = MyObj(self.context)
self.assertRaises(object_exception.InvalidTargetVersion,
serializer.serialize_entity, self.context, obj)
@mock.patch('ironic.common.release_mappings.RELEASE_MAPPING')
def test_serialize_entity_no_pin(self, mock_release_mapping):
CONF.set_override('pin_release_version',
release_mappings.RELEASE_VERSIONS[-1],
enforce_type=True)
mock_release_mapping.__getitem__.return_value = {
'objects': {}
}
serializer = base.IronicObjectSerializer()
obj = MyObj(self.context)
primitive = serializer.serialize_entity(self.context, obj)
self.assertEqual('1.5', primitive['ironic_object.version'])
@mock.patch('ironic.objects.base.IronicObject._get_target_version')
@mock.patch('ironic.objects.base.LOG.warning')
def test_serialize_entity_unknown_entity(self, mock_warn, mock_version):
class Foo(object):
fields = {'foobar': fields.IntegerField()}
serializer = base.IronicObjectSerializer()
obj = Foo()
primitive = serializer.serialize_entity(self.context, obj)
self.assertEqual(obj, primitive)
self.assertTrue(mock_warn.called)
mock_version.assert_not_called()
class TestRegistry(test_base.TestCase): class TestRegistry(test_base.TestCase):
@mock.patch('ironic.objects.base.objects') @mock.patch('ironic.objects.base.objects')