diff --git a/etc/providers.d/openstack-infra.conf b/etc/providers.d/openstack-infra.conf index c62e36e5..fa2db0fd 100644 --- a/etc/providers.d/openstack-infra.conf +++ b/etc/providers.d/openstack-infra.conf @@ -8,6 +8,7 @@ plugin=karbor-image-protection-plugin plugin=karbor-server-protection-plugin plugin=karbor-share-protection-plugin plugin=karbor-network-protection-plugin +plugin=karbor-database-protection-plugin bank=karbor-swift-bank-plugin enabled=True diff --git a/karbor/services/protection/protection_plugins/database/__init__.py b/karbor/services/protection/protection_plugins/database/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/karbor/services/protection/protection_plugins/database/database_backup_plugin.py b/karbor/services/protection/protection_plugins/database/database_backup_plugin.py new file mode 100644 index 00000000..37166a6f --- /dev/null +++ b/karbor/services/protection/protection_plugins/database/database_backup_plugin.py @@ -0,0 +1,305 @@ +# 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 functools import partial +import six + +from karbor.common import constants +from karbor import exception +from karbor.services.protection.client_factory import ClientFactory +from karbor.services.protection import protection_plugin +from karbor.services.protection.protection_plugins.database \ + import database_backup_plugin_schemas as database_instance_schemas +from karbor.services.protection.protection_plugins import utils +from oslo_config import cfg +from oslo_log import log as logging +from troveclient import exceptions as trove_exc + +LOG = logging.getLogger(__name__) + +trove_backup_opts = [ + cfg.IntOpt( + 'poll_interval', default=15, + help='Poll interval for Trove Database Instance status.' + ) +] + +DATABASE_FAILURE_STATUSES = {'BLOCKED', 'FAILED', 'REBOOT', + 'SHUTDOWN', 'ERROR', + 'RESTART_REQUIRED', 'EJECT', 'DETACH'} + +DATABASE_IGNORE_STATUSES = {'BUILD', 'RESIZE', 'BACKUP', 'PROMOTE', 'UPGRADE'} + + +def get_backup_status(trove_client, backup_id): + return get_resource_status(trove_client.backups, backup_id, + 'backup') + + +def get_database_instance_status(trove_client, instance_id): + return get_resource_status(trove_client.instances, instance_id, 'instance') + + +def get_resource_status(resource_manager, resource_id, resource_type): + LOG.debug('Polling %(resource_type)s (id: %(resource_id)s)', + {'resource_type': resource_type, 'resource_id': resource_id}) + try: + resource = resource_manager.get(resource_id) + status = resource.status + except trove_exc.NotFound: + status = 'not-found' + LOG.debug( + 'Polled %(resource_type)s (id: %(resource_id)s) status: %(status)s', + {'resource_type': resource_type, 'resource_id': resource_id, + 'status': status} + ) + return status + + +class ProtectOperation(protection_plugin.Operation): + def __init__(self, poll_interval): + super(ProtectOperation, self).__init__() + self._interval = poll_interval + + def _create_backup(self, trove_client, instance_id, backup_name, + description): + backup = trove_client.backups.create( + backup_name, + instance=instance_id, + description=description + ) + + backup_id = backup.id + is_success = utils.status_poll( + partial(get_backup_status, trove_client, backup_id), + interval=self._interval, + success_statuses={'COMPLETED'}, + failure_statuses={'FAILED'}, + ignore_statuses={'BUILDING'}, + ignore_unexpected=True + ) + + if not is_success: + try: + backup = trove_client.backups.get(backup_id) + except Exception: + reason = 'Unable to find backup.' + else: + reason = 'The status of backup is %s' % backup.status + raise exception.CreateResourceFailed( + name="Database Instance Backup", + reason=reason, resource_id=instance_id, + resource_type=constants.DATABASE_RESOURCE_TYPE) + + return backup_id + + def on_main(self, checkpoint, resource, context, parameters, **kwargs): + instance_id = resource.id + bank_section = checkpoint.get_resource_bank_section(instance_id) + trove_client = ClientFactory.create_client('trove', context) + LOG.info('creating database instance backup, instance_id: %s', + instance_id) + bank_section.update_object('status', + constants.RESOURCE_STATUS_PROTECTING) + instance_info = trove_client.instances.get(instance_id) + if instance_info.status != "ACTIVE": + is_success = utils.status_poll( + partial(get_database_instance_status, trove_client, + instance_id), + interval=self._interval, success_statuses={'ACTIVE'}, + failure_statuses=DATABASE_FAILURE_STATUSES, + ignore_statuses=DATABASE_IGNORE_STATUSES, + ) + if not is_success: + bank_section.update_object('status', + constants.RESOURCE_STATUS_ERROR) + raise exception.CreateResourceFailed( + name="Database instance Backup", + reason='Database instance is in a error status.', + resource_id=instance_id, + resource_type=constants.DATABASE_RESOURCE_TYPE, + ) + resource_metadata = { + 'instance_id': instance_id, + 'datastore': instance_info.datastore, + 'flavor': instance_info.flavor, + 'size': instance_info.volume['size'], + } + backup_name = parameters.get('backup_name', 'backup%s' % ( + instance_id)) + description = parameters.get('description', None) + try: + backup_id = self._create_backup( + trove_client, instance_id, backup_name, description) + except exception.CreateResourceFailed as e: + LOG.error('Error creating backup (instance_id: %(instance_id)s ' + ': %(reason)s', {'instance_id': instance_id, + 'reason': e}) + bank_section.update_object('status', + constants.RESOURCE_STATUS_ERROR) + raise + + resource_metadata['backup_id'] = backup_id + + bank_section.update_object('metadata', resource_metadata) + bank_section.update_object('status', + constants.RESOURCE_STATUS_AVAILABLE) + LOG.info('Backup database instance (instance_id: %(instance_id)s ' + 'backup_id: %(backup_id)s ) successfully', + {'instance_id': instance_id, 'backup_id': backup_id}) + + +class RestoreOperation(protection_plugin.Operation): + def __init__(self, poll_interval): + super(RestoreOperation, self).__init__() + self._interval = poll_interval + + def on_main(self, checkpoint, resource, context, parameters, **kwargs): + original_instance_id = resource.id + bank_section = checkpoint.get_resource_bank_section( + original_instance_id) + trove_client = ClientFactory.create_client('trove', context) + resource_metadata = bank_section.get_object('metadata') + restore_name = parameters.get('restore_name', + '%s@%s' % (checkpoint.id, + original_instance_id)) + flavor = resource_metadata['flavor'] + size = resource_metadata['size'] + backup_id = resource_metadata['backup_id'] + restore = kwargs.get('restore') + LOG.info("Restoring a database instance from backup, " + "original_instance_id: %s.", original_instance_id) + + try: + instance_info = trove_client.instances.create( + restore_name, flavor["id"], volume={"size": size}, + restorePoint={"backupRef": backup_id}) + is_success = utils.status_poll( + partial(get_database_instance_status, trove_client, + instance_info.id), + interval=self._interval, success_statuses={'ACTIVE'}, + failure_statuses=DATABASE_FAILURE_STATUSES, + ignore_statuses=DATABASE_IGNORE_STATUSES + ) + if is_success is not True: + LOG.error('The status of database instance is ' + 'invalid. status:%s', instance_info.status) + restore.update_resource_status( + constants.DATABASE_RESOURCE_TYPE, + instance_info.id, instance_info.status, + "Invalid status.") + restore.save() + raise exception.RestoreResourceFailed( + name="Database instance Backup", + reason="Invalid status.", + resource_id=original_instance_id, + resource_type=constants.DATABASE_RESOURCE_TYPE) + restore.update_resource_status( + constants.DATABASE_RESOURCE_TYPE, + instance_info.id, instance_info.status) + restore.save() + except Exception as e: + LOG.error("Restore Database instance from backup " + "failed, instance_id: %s.", original_instance_id) + raise exception.RestoreResourceFailed( + name="Database instance Backup", + reason=e, resource_id=original_instance_id, + resource_type=constants.DATABASE_RESOURCE_TYPE) + LOG.info("Finish restoring a Database instance from backup," + "instance_id: %s.", original_instance_id) + + +class DeleteOperation(protection_plugin.Operation): + def __init__(self, poll_interval): + super(DeleteOperation, self).__init__() + self._interval = poll_interval + + def on_main(self, checkpoint, resource, context, parameters, **kwargs): + resource_id = resource.id + bank_section = checkpoint.get_resource_bank_section(resource_id) + backup_id = None + try: + bank_section.update_object('status', + constants.RESOURCE_STATUS_DELETING) + resource_metadata = bank_section.get_object('metadata') + backup_id = resource_metadata['backup_id'] + trove_client = ClientFactory.create_client('trove', context) + try: + backup = trove_client.backups.get(backup_id) + trove_client.backups.delete(backup) + except trove_exc.NotFound: + LOG.info('Backup id: %s not found. Assuming deleted', + backup_id) + is_success = utils.status_poll( + partial(get_backup_status, trove_client, backup_id), + interval=self._interval, + success_statuses={'not-found'}, + failure_statuses={'FAILED', 'DELETE_FAILED'}, + ignore_statuses={'COMPLETED'}, + ignore_unexpected=True + ) + if not is_success: + raise exception.NotFound() + bank_section.delete_object('metadata') + bank_section.update_object('status', + constants.RESOURCE_STATUS_DELETED) + except Exception as e: + LOG.error('Delete Database instance Backup failed, backup_id: %s', + backup_id) + bank_section.update_object('status', + constants.RESOURCE_STATUS_ERROR) + raise exception.DeleteResourceFailed( + name="Database instance Backup", + reason=six.text_type(e), + resource_id=resource_id, + resource_type=constants.DATABASE_RESOURCE_TYPE + ) + + +class DatabaseBackupProtectionPlugin(protection_plugin.ProtectionPlugin): + _SUPPORT_RESOURCE_TYPES = [constants.DATABASE_RESOURCE_TYPE] + + def __init__(self, config=None): + super(DatabaseBackupProtectionPlugin, self).__init__(config) + self._config.register_opts(trove_backup_opts, + 'database_backup_plugin') + self._plugin_config = self._config.database_backup_plugin + self._poll_interval = self._plugin_config.poll_interval + + @classmethod + def get_supported_resources_types(cls): + return cls._SUPPORT_RESOURCE_TYPES + + @classmethod + def get_options_schema(cls, resources_type): + return database_instance_schemas.OPTIONS_SCHEMA + + @classmethod + def get_restore_schema(cls, resources_type): + return database_instance_schemas.RESTORE_SCHEMA + + @classmethod + def get_saved_info_schema(cls, resources_type): + return database_instance_schemas.SAVED_INFO_SCHEMA + + @classmethod + def get_saved_info(cls, metadata_store, resource): + pass + + def get_protect_operation(self, resource): + return ProtectOperation(self._poll_interval) + + def get_restore_operation(self, resource): + return RestoreOperation(self._poll_interval) + + def get_delete_operation(self, resource): + return DeleteOperation(self._poll_interval) diff --git a/karbor/services/protection/protection_plugins/database/database_backup_plugin_schemas.py b/karbor/services/protection/protection_plugins/database/database_backup_plugin_schemas.py new file mode 100644 index 00000000..b46949b9 --- /dev/null +++ b/karbor/services/protection/protection_plugins/database/database_backup_plugin_schemas.py @@ -0,0 +1,51 @@ +# 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. + +OPTIONS_SCHEMA = { + "title": "Database Instance Protection Options", + "type": "object", + "properties": { + "backup_name": { + "type": "string", + "title": "Backup Name", + "description": "The name of the backup.", + "default": None + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the backup." + } + }, + "required": ["backup_name"] +} + +RESTORE_SCHEMA = { + "title": "Database Instance Protection Restore", + "type": "object", + "properties": { + "restore_name": { + "type": "string", + "title": "Restore Database Instance Name", + "description": "The name of the restore Database Instance", + "default": None + }, + }, + "required": ["restore_name"] +} + +SAVED_INFO_SCHEMA = { + "title": "Database Instance Protection Saved Info", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/karbor/tests/unit/protection/test_database_protection_plugin.py b/karbor/tests/unit/protection/test_database_protection_plugin.py new file mode 100644 index 00000000..42ec9a3f --- /dev/null +++ b/karbor/tests/unit/protection/test_database_protection_plugin.py @@ -0,0 +1,200 @@ +# 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 collections +from karbor.common import constants +from karbor.context import RequestContext +from karbor.resource import Resource +from karbor.services.protection.bank_plugin import Bank +from karbor.services.protection.bank_plugin import BankPlugin +from karbor.services.protection.bank_plugin import BankSection +from karbor.services.protection import client_factory + +from karbor.services.protection.protection_plugins. \ + database.database_backup_plugin import DatabaseBackupProtectionPlugin +from karbor.services.protection.protection_plugins.database \ + import database_backup_plugin_schemas + +from karbor.tests import base +import mock +from oslo_config import cfg +from oslo_config import fixture + + +class FakeBankPlugin(BankPlugin): + def update_object(self, key, value): + return + + def get_object(self, key): + return + + def list_objects(self, prefix=None, limit=None, marker=None, + sort_dir=None): + return + + def delete_object(self, key): + return + + def get_owner_id(self): + return + + +fake_bank = Bank(FakeBankPlugin()) +fake_bank_section = BankSection(bank=fake_bank, section="fake") + +ResourceNode = collections.namedtuple( + "ResourceNode", + ["value", + "child_nodes"] +) + +Database = collections.namedtuple( + "Database", + ["status"] +) + +Backup = collections.namedtuple( + "Backup", + ["id", "status"] +) + + +def call_hooks(operation, checkpoint, resource, context, parameters, **kwargs): + def noop(*args, **kwargs): + pass + + hooks = ( + 'on_prepare_begin', + 'on_prepare_finish', + 'on_main', + 'on_complete', + ) + for hook_name in hooks: + hook = getattr(operation, hook_name, noop) + hook(checkpoint, resource, context, parameters, **kwargs) + + +class CheckpointCollection(object): + def __init__(self): + self.bank_section = fake_bank_section + + def get_resource_bank_section(self, resource_id): + return self.bank_section + + +class TroveProtectionPluginTest(base.TestCase): + def setUp(self): + super(TroveProtectionPluginTest, self).setUp() + + plugin_config = cfg.ConfigOpts() + plugin_config_fixture = self.useFixture(fixture.Config(plugin_config)) + plugin_config_fixture.load_raw_values( + group='database_backup_plugin', + poll_interval=0, + ) + + self.plugin = DatabaseBackupProtectionPlugin(plugin_config) + + cfg.CONF.set_default('trove_endpoint', + 'http://127.0.0.1:8774/v2.1', + 'trove_client') + service_catalog = [ + {'type': 'database', + 'endpoints': [{'publicURL': 'http://127.0.0.1:8774/v2.1/abcd'}], + }, + ] + self.cntxt = RequestContext(user_id='demo', + project_id='abcd', + auth_token='efgh', + service_catalog=service_catalog) + self.trove_client = client_factory.ClientFactory.create_client( + "trove", self.cntxt) + self.checkpoint = CheckpointCollection() + + def test_get_options_schema(self): + options_schema = self.plugin.get_options_schema( + constants.DATABASE_RESOURCE_TYPE) + self.assertEqual(options_schema, + database_backup_plugin_schemas.OPTIONS_SCHEMA) + + def test_get_restore_schema(self): + options_schema = self.plugin.get_restore_schema( + constants.DATABASE_RESOURCE_TYPE) + self.assertEqual(options_schema, + database_backup_plugin_schemas.RESTORE_SCHEMA) + + def test_get_saved_info_schema(self): + options_schema = self.plugin.get_saved_info_schema( + constants.DATABASE_RESOURCE_TYPE) + self.assertEqual(options_schema, + database_backup_plugin_schemas.SAVED_INFO_SCHEMA) + + @mock.patch('karbor.services.protection.protection_plugins.database.' + 'database_backup_plugin.utils.status_poll') + @mock.patch('karbor.services.protection.clients.trove.create') + def test_create_backup(self, mock_trove_create, mock_status_poll): + resource = Resource(id="123", + type=constants.DATABASE_RESOURCE_TYPE, + name='fake') + + fake_bank_section.update_object = mock.MagicMock() + + protect_operation = self.plugin.get_protect_operation(resource) + mock_trove_create.return_value = self.trove_client + + self.trove_client.instances.get = mock.MagicMock() + self.trove_client.instances.return_value = Database( + status="ACTIVE" + ) + fake_bank_section.update_object = mock.MagicMock() + self.trove_client.backups.create = mock.MagicMock() + self.trove_client.backups.create.return_value = Backup( + id="1234", + status="COMPLETED" + ) + self.trove_client.backups.get = mock.MagicMock() + self.trove_client.backups.get.return_value = Backup( + id="1234", + status="COMPLETED" + ) + mock_status_poll.return_value = True + call_hooks(protect_operation, self.checkpoint, resource, self.cntxt, + {}) + + @mock.patch('karbor.services.protection.protection_plugins.database.' + 'database_backup_plugin.utils.status_poll') + @mock.patch('karbor.services.protection.clients.trove.create') + def test_delete_backup(self, mock_trove_create, mock_status_poll): + resource = Resource(id="123", + type=constants.DATABASE_RESOURCE_TYPE, + name='fake') + mock_trove_create.return_value = self.trove_client + self.trove_client.backups.get = mock.MagicMock() + self.trove_client.backups.get.return_value = Backup( + id="1234", + status="COMPLETED" + ) + self.trove_client.backups.delete = mock.MagicMock() + + fake_bank_section.get_object = mock.MagicMock() + fake_bank_section.get_object.return_value = { + "backup_id": "1234"} + + mock_status_poll.return_value = True + delete_operation = self.plugin.get_delete_operation(resource) + call_hooks(delete_operation, self.checkpoint, resource, self.cntxt, + {}) + + def test_get_supported_resources_types(self): + types = self.plugin.get_supported_resources_types() + self.assertEqual(types, + [constants.DATABASE_RESOURCE_TYPE]) diff --git a/setup.cfg b/setup.cfg index d125aeba..bfdb0e85 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,6 +45,7 @@ karbor.protections = karbor-share-protection-plugin = karbor.services.protection.protection_plugins.share.share_snapshot_plugin:ManilaSnapshotProtectionPlugin karbor-noop-protection-plugin = karbor.services.protection.protection_plugins.noop_plugin:NoopProtectionPlugin karbor-network-protection-plugin = karbor.services.protection.protection_plugins.network.neutron_protection_plugin:NeutronProtectionPlugin + karbor-database-protection-plugin = karbor.services.protection.protection_plugins.database.database_backup_plugin:DatabaseBackupProtectionPlugin karbor.provider = provider-registry = karbor.services.protection.provider:ProviderRegistry karbor.protectables =