diff --git a/heat/engine/resources/openstack/trove/os_database.py b/heat/engine/resources/openstack/trove/os_database.py index 214023bf72..6f905740c9 100644 --- a/heat/engine/resources/openstack/trove/os_database.py +++ b/heat/engine/resources/openstack/trove/os_database.py @@ -12,6 +12,7 @@ # under the License. from oslo_log import log as logging +import six from heat.common import exception from heat.common.i18n import _ @@ -91,6 +92,7 @@ class OSDBInstance(resource.Resource): NAME: properties.Schema( properties.Schema.STRING, _('Name of the DB instance to create.'), + update_allowed=True, constraints=[ constraints.Length(max=255), ] @@ -99,6 +101,7 @@ class OSDBInstance(resource.Resource): properties.Schema.STRING, _('Reference to a flavor for creating DB instance.'), required=True, + update_allowed=True, constraints=[ constraints.CustomConstraint('trove.flavor') ] @@ -123,6 +126,7 @@ class OSDBInstance(resource.Resource): properties.Schema.INTEGER, _('Database volume size in GB.'), required=True, + update_allowed=True, constraints=[ constraints.Range(1, 150), ] @@ -167,6 +171,7 @@ class OSDBInstance(resource.Resource): properties.Schema.LIST, _('List of databases to be created on DB instance creation.'), default=[], + update_allowed=True, schema=properties.Schema( properties.Schema.MAP, schema={ @@ -200,6 +205,7 @@ class OSDBInstance(resource.Resource): properties.Schema.LIST, _('List of users to be created on DB instance creation.'), default=[], + update_allowed=True, schema=properties.Schema( properties.Schema.MAP, schema={ @@ -208,6 +214,7 @@ class OSDBInstance(resource.Resource): _('User name to create a user on instance ' 'creation.'), required=True, + update_allowed=True, constraints=[ constraints.Length(max=16), constraints.AllowedPattern(r'[a-zA-Z0-9_]+' @@ -220,6 +227,7 @@ class OSDBInstance(resource.Resource): _('Password for those users on instance ' 'creation.'), required=True, + update_allowed=True, constraints=[ constraints.AllowedPattern(r'[a-zA-Z0-9_]+' r'[a-zA-Z0-9_@?#\s]*' @@ -230,7 +238,8 @@ class OSDBInstance(resource.Resource): properties.Schema.STRING, _('The host from which a user is allowed to ' 'connect to the database.'), - default='%' + default='%', + update_allowed=True ), USER_DATABASES: properties.Schema( properties.Schema.LIST, @@ -240,6 +249,7 @@ class OSDBInstance(resource.Resource): properties.Schema.STRING, ), required=True, + update_allowed=True, constraints=[ constraints.Length(min=1), ] @@ -414,6 +424,170 @@ class OSDBInstance(resource.Resource): ] self._verify_check_conditions(checks) + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + updates = {} + if prop_diff: + instance = self.client().instances.get(self.resource_id) + if self.NAME in prop_diff: + updates.update({self.NAME: prop_diff[self.NAME]}) + if self.FLAVOR in prop_diff: + flvid = prop_diff[self.FLAVOR] + flv = self.client_plugin().get_flavor_id(flvid) + updates.update({self.FLAVOR: flv}) + if self.SIZE in prop_diff: + updates.update({self.SIZE: prop_diff[self.SIZE]}) + if self.DATABASES in prop_diff: + current = [d.name + for d in self.client().databases.list(instance)] + desired = [d[self.DATABASE_NAME] + for d in prop_diff[self.DATABASES]] + for db in prop_diff[self.DATABASES]: + dbname = db[self.DATABASE_NAME] + if dbname not in current: + db['ACTION'] = self.CREATE + for dbname in current: + if dbname not in desired: + deleted = {self.DATABASE_NAME: dbname, + 'ACTION': self.DELETE} + prop_diff[self.DATABASES].append(deleted) + updates.update({self.DATABASES: prop_diff[self.DATABASES]}) + if self.USERS in prop_diff: + current = [u.name + for u in self.client().users.list(instance)] + desired = [u[self.USER_NAME] for u in prop_diff[self.USERS]] + for usr in prop_diff[self.USERS]: + if usr[self.USER_NAME] not in current: + usr['ACTION'] = self.CREATE + for usr in current: + if usr not in desired: + prop_diff[self.USERS].append({self.USER_NAME: usr, + 'ACTION': self.DELETE}) + updates.update({self.USERS: prop_diff[self.USERS]}) + return updates + + def check_update_complete(self, updates): + instance = self.client().instances.get(self.resource_id) + if instance.status in self.BAD_STATUSES: + raise exception.ResourceInError( + resource_status=instance.status, + status_reason=self.TROVE_STATUS_REASON.get(instance.status, + _("Unknown"))) + if updates: + if instance.status != self.ACTIVE: + dmsg = ("Instance is in status %(now)s. Waiting on status" + " %(stat)s") + LOG.debug(dmsg % {"now": instance.status, + "stat": self.ACTIVE}) + return False + try: + return ( + self._update_name(instance, updates.get(self.NAME)) and + self._update_flavor(instance, updates.get(self.FLAVOR)) and + self._update_size(instance, updates.get(self.SIZE)) and + self._update_databases(instance, + updates.get(self.DATABASES)) and + self._update_users(instance, updates.get(self.USERS)) + ) + except Exception as exc: + if self.client_plugin().is_client_exception(exc): + # the instance could have updated between the time + # we retrieve it and try to update it so check again + if self.client_plugin().is_over_limit(exc): + LOG.debug("API rate limit: %(ex)s. Retrying." % + {'ex': six.text_type(exc)}) + return False + if "No change was requested" in six.text_type(exc): + LOG.warn(_LW("Unexpected instance state change " + "during update. Retrying.")) + return False + raise exc + return True + + def _update_name(self, instance, name): + if name and instance.name != name: + self.client().instances.edit(instance, name=name) + return False + return True + + def _update_flavor(self, instance, new_flavor): + if new_flavor: + current_flav = six.text_type(instance.flavor['id']) + new_flav = six.text_type(new_flavor) + if new_flav != current_flav: + dmsg = "Resizing instance flavor from %(old)s to %(new)s" + LOG.debug(dmsg % {"old": current_flav, "new": new_flav}) + self.client().instances.resize_instance(instance, new_flavor) + return False + return True + + def _update_size(self, instance, new_size): + if new_size and instance.volume['size'] != new_size: + dmsg = "Resizing instance storage from %(old)s to %(new)s" + LOG.debug(dmsg % {"old": instance.volume['size'], + "new": new_size}) + self.client().instances.resize_volume(instance, new_size) + return False + return True + + def _update_databases(self, instance, databases): + if databases: + for db in databases: + if db.get("ACTION") == self.CREATE: + db.pop("ACTION", None) + dmsg = "Adding new database %(db)s to instance" + LOG.debug(dmsg % {"db": db}) + self.client().databases.create(instance, [db]) + elif db.get("ACTION") == self.DELETE: + dmsg = ("Deleting existing database %(db)s from " + "instance") + LOG.debug(dmsg % {"db": db['name']}) + self.client().databases.delete(instance, db['name']) + return True + + def _update_users(self, instance, users): + if users: + for usr in users: + dbs = [{'name': db} for db in usr.get(self.USER_DATABASES, + [])] + usr[self.USER_DATABASES] = dbs + if usr.get("ACTION") == self.CREATE: + usr.pop("ACTION", None) + dmsg = "Adding new user %(u)s to instance" + LOG.debug(dmsg % {"u": usr}) + self.client().users.create(instance, [usr]) + elif usr.get("ACTION") == self.DELETE: + dmsg = ("Deleting existing user %(u)s from " + "instance") + LOG.debug(dmsg % {"u": usr['name']}) + self.client().users.delete(instance, usr['name']) + else: + newattrs = {} + if usr.get(self.USER_HOST): + newattrs[self.USER_HOST] = usr[self.USER_HOST] + if usr.get(self.USER_PASSWORD): + newattrs[self.USER_PASSWORD] = usr[self.USER_PASSWORD] + if newattrs: + self.client().users.update_attributes( + instance, + usr['name'], newuserattr=newattrs, + hostname=instance.hostname) + current = self.client().users.get(instance, + usr[self.USER_NAME]) + dbs = [db['name'] for db in current.databases] + desired = [db['name'] for db in + usr.get(self.USER_DATABASES, [])] + grants = [db for db in desired if db not in dbs] + revokes = [db for db in dbs if db not in desired] + if grants: + self.client().users.grant(instance, + usr[self.USER_NAME], + grants) + if revokes: + self.client().users.revoke(instance, + usr[self.USER_NAME], + revokes) + return True + def handle_delete(self): """Delete a cloud database instance.""" if not self.resource_id: diff --git a/heat/tests/openstack/trove/test_os_database.py b/heat/tests/openstack/trove/test_os_database.py index 72b0b89309..7f62739d49 100644 --- a/heat/tests/openstack/trove/test_os_database.py +++ b/heat/tests/openstack/trove/test_os_database.py @@ -11,17 +11,20 @@ # License for the specific language governing permissions and limitations # under the License. +import testtools import uuid import mock import six from troveclient.openstack.common.apiclient import exceptions as troveexc +from troveclient.v1 import users from heat.common import exception from heat.common import template_format from heat.engine.clients.os import neutron from heat.engine.clients.os import nova from heat.engine.clients.os import trove +from heat.engine import resource from heat.engine.resources.openstack.trove import os_database from heat.engine import rsrc_defn from heat.engine import scheduler @@ -742,3 +745,274 @@ class OSDBInstanceTest(common.HeatTestCase): scheduler.TaskRunner(instance.create)() self.assertEqual((instance.CREATE, instance.COMPLETE), instance.state) self.m.VerifyAll() + + +@mock.patch.object(resource.Resource, "client_plugin") +@mock.patch.object(resource.Resource, "client") +class InstanceUpdateTests(testtools.TestCase): + + def setUp(self): + super(InstanceUpdateTests, self).setUp() + resource._register_class("OS::Trove::Instance", + os_database.OSDBInstance) + self._stack = mock.Mock() + self._stack.has_cache_data.return_value = False + self._stack.db_resource_get.return_value = None + testprops = { + "name": "testinstance", + "flavor": "foo", + "datastore_type": "database", + "datastore_version": "1", + "size": 10, + "databases": [ + {"name": "bar"}, + {"name": "biff"} + ], + "users": [ + { + "name": "baz", + "password": "password", + "databases": ["bar"] + }, + { + "name": "deleted", + "password": "password", + "databases": ["biff"] + } + ] + } + self._rdef = rsrc_defn.ResourceDefinition('test', + os_database.OSDBInstance, + properties=testprops) + + def test_handle_no_update(self, mock_client, mock_plugin): + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertEqual({}, trove.handle_update(None, None, {})) + + def test_handle_update_name(self, mock_client, mock_plugin): + prop_diff = { + "name": "changed" + } + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertEqual(prop_diff, trove.handle_update(None, None, prop_diff)) + + def test_handle_update_databases(self, mock_client, mock_plugin): + prop_diff = { + "databases": [ + {"name": "bar", + "character_set": "ascii"}, + {'name': "baz"} + ] + } + mget = mock_client().databases.list + mbar = mock.Mock(name='bar') + mbar.name = 'bar' + mbiff = mock.Mock(name='biff') + mbiff.name = 'biff' + mget.return_value = [mbar, mbiff] + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + expected = { + 'databases': [ + {'character_set': 'ascii', 'name': 'bar'}, + {'ACTION': 'CREATE', 'name': 'baz'}, + {'ACTION': 'DELETE', 'name': 'biff'} + ]} + self.assertEqual(expected, trove.handle_update(None, None, prop_diff)) + + def test_handle_update_users(self, mock_client, mock_plugin): + prop_diff = { + "users": [ + {"name": "baz", + "password": "changed", + "databases": ["bar", "biff"]}, + {'name': "user2", + "password": "password", + "databases": ["biff", "bar"]} + ] + } + uget = mock_client().users + mbaz = mock.Mock(name='baz') + mbaz.name = 'baz' + mdel = mock.Mock(name='deleted') + mdel.name = 'deleted' + uget.list.return_value = [mbaz, mdel] + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + expected = { + 'users': [{ + 'databases': ['bar', 'biff'], + 'name': 'baz', + 'password': 'changed' + }, { + 'ACTION': 'CREATE', + 'databases': ['biff', 'bar'], + 'name': 'user2', + 'password': 'password' + }, { + 'ACTION': 'DELETE', + 'name': 'deleted' + }]} + self.assertEqual(expected, trove.handle_update(None, None, prop_diff)) + + def test_handle_update_flavor(self, mock_client, mock_plugin): + prop_diff = { + "flavor": "changed" + } + mock_plugin().get_flavor_id.return_value = 1234 + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + expected = { + "flavor": 1234 + } + self.assertEqual(expected, trove.handle_update(None, None, prop_diff)) + + def test_handle_update_size(self, mock_client, mock_plugin): + prop_diff = { + "size": 42 + } + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + expected = { + "size": 42 + } + self.assertEqual(expected, trove.handle_update(None, None, prop_diff)) + + def test_check_complete_none(self, mock_client, mock_plugin): + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertTrue(trove.check_update_complete({})) + + def test_check_complete_error(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ERROR") + mock_client().instances.get.return_value = mock_instance + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + exc = self.assertRaises(exception.ResourceInError, + trove.check_update_complete, + {"foo": "bar"}) + msg = "The last operation for the database instance failed" + self.assertIn(msg, six.text_type(exc)) + + def test_check_client_exceptions(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ACTIVE") + mock_client().instances.get.return_value = mock_instance + mock_plugin().is_client_exception.return_value = True + mock_plugin().is_over_limit.side_effect = [True, False] + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + with mock.patch.object(trove, "_update_flavor") as mupdate: + mupdate.side_effect = [Exception("test"), + Exception("No change was requested " + "because I'm testing")] + self.assertFalse(trove.check_update_complete({"foo": "bar"})) + self.assertFalse(trove.check_update_complete({"foo": "bar"})) + self.assertEqual(2, mupdate.call_count) + self.assertEqual(2, mock_plugin().is_client_exception.call_count) + + def test_check_complete_status(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="RESIZING") + mock_client().instances.get.return_value = mock_instance + updates = {"foo": "bar"} + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertFalse(trove.check_update_complete(updates)) + + def test_check_complete_name(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ACTIVE", name="mock_instance") + mock_client().instances.get.return_value = mock_instance + updates = {"name": "changed"} + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertFalse(trove.check_update_complete(updates)) + mock_instance.name = "changed" + self.assertTrue(trove.check_update_complete(updates)) + mock_client().instances.edit.assert_called_once_with(mock_instance, + name="changed") + + def test_check_complete_databases(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ACTIVE", name="mock_instance") + mock_client().instances.get.return_value = mock_instance + updates = { + 'databases': [ + {'name': 'bar', "character_set": "ascii"}, + {'ACTION': 'CREATE', 'name': 'baz'}, + {'ACTION': 'DELETE', 'name': 'biff'} + ]} + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertTrue(trove.check_update_complete(updates)) + mcreate = mock_client().databases.create + mdelete = mock_client().databases.delete + mcreate.assert_called_once_with(mock_instance, [{'name': 'baz'}]) + mdelete.assert_called_once_with(mock_instance, 'biff') + + def test_check_complete_users(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ACTIVE", name="mock_instance") + mock_client().instances.get.return_value = mock_instance + mock_plugin().is_client_exception.return_value = False + mock_client().users.get.return_value = users.User(None, { + "databases": [{ + "name": "bar" + }, { + "name": "buzz" + }], + "name": "baz" + }, loaded=True) + updates = { + 'users': [{ + 'databases': ['bar', 'biff'], + 'name': 'baz', + 'password': 'changed' + }, { + 'ACTION': 'CREATE', + 'databases': ['biff', 'bar'], + 'name': 'user2', + 'password': 'password' + }, { + 'ACTION': 'DELETE', + 'name': 'deleted' + }]} + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertTrue(trove.check_update_complete(updates)) + create_calls = [ + mock.call(mock_instance, [{'password': 'password', + 'databases': [{'name': 'biff'}, + {'name': 'bar'}], + 'name': 'user2'}]) + ] + delete_calls = [ + mock.call(mock_instance, 'deleted') + ] + mock_client().users.create.assert_has_calls(create_calls) + mock_client().users.delete.assert_has_calls(delete_calls) + self.assertEqual(1, mock_client().users.create.call_count) + self.assertEqual(1, mock_client().users.delete.call_count) + updateattr = mock_client().users.update_attributes + updateattr.assert_called_once_with( + mock_instance, 'baz', newuserattr={'password': 'changed'}, + hostname=mock.ANY) + mock_client().users.grant.assert_called_once_with( + mock_instance, 'baz', ['biff']) + mock_client().users.revoke.assert_called_once_with( + mock_instance, 'baz', ['buzz']) + + def test_check_complete_flavor(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ACTIVE", flavor={'id': 4567}, + name="mock_instance") + mock_client().instances.get.return_value = mock_instance + updates = { + "flavor": 1234 + } + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertFalse(trove.check_update_complete(updates)) + mock_instance.status = "RESIZING" + self.assertFalse(trove.check_update_complete(updates)) + mock_instance.status = "ACTIVE" + mock_instance.flavor = {'id': 1234} + self.assertTrue(trove.check_update_complete(updates)) + + def test_check_complete_size(self, mock_client, mock_plugin): + mock_instance = mock.Mock(status="ACTIVE", volume={'size': 24}, + name="mock_instance") + mock_client().instances.get.return_value = mock_instance + updates = { + "size": 42 + } + trove = os_database.OSDBInstance('test', self._rdef, self._stack) + self.assertFalse(trove.check_update_complete(updates)) + mock_instance.status = "RESIZING" + self.assertFalse(trove.check_update_complete(updates)) + mock_instance.status = "ACTIVE" + mock_instance.volume = {'size': 42} + self.assertTrue(trove.check_update_complete(updates))