Support to restore backup from remote location

In multi-region deployment with geo-replicated Swift, the user can
restore a backup in one region by manually specifying the original
backup data location created in another region.

Change-Id: Iefef3bf969163af707935445bc23299400dc88c3
This commit is contained in:
Lingxian Kong 2021-02-16 12:23:19 +13:00
parent 9c2e0bf3a0
commit 6fdf11ea7f
15 changed files with 328 additions and 64 deletions

View File

@ -76,6 +76,19 @@ In the Trove deployment with service tenant enabled, The backup data is
stored as objects in OpenStack Swift service in the user's container. If not stored as objects in OpenStack Swift service in the user's container. If not
specified, the container name is defined by the cloud admin. specified, the container name is defined by the cloud admin.
The user can create a backup strategy within the project scope or specific to
a particular instance.
In multi-region deployment with geo-replicated Swift, the user can also restore
a backup in a region by manually specifying the backup data location created in
another region, then create instances from the backup. Instance ID is not
required in this case.
.. warning::
The restored backup is dependent on the original backup data, if the
original backup is deleted, the restored backup is invalid.
Normal response codes: 202 Normal response codes: 202
Request Request
@ -90,6 +103,7 @@ Request
- incremental: backup_incremental - incremental: backup_incremental
- description: backup_description - description: backup_description
- swift_container: swift_container - swift_container: swift_container
- restore_from: backup_restore_from
Request Example Request Example
--------------- ---------------

View File

@ -148,7 +148,7 @@ backup_instanceId:
description: | description: |
The ID of the instance to create backup for. The ID of the instance to create backup for.
in: body in: body
required: true required: false
type: string type: string
backup_list: backup_list:
description: | description: |
@ -180,6 +180,17 @@ backup_parentId1:
in: body in: body
required: true required: true
type: string type: string
backup_restore_from:
description: |
The information needed to restore a backup, including:
- ``remote_location``: The original backup data location.
- ``local_datastore_version_id``: The local datastore version corresponding
to the original backup.
- ``size``: The original backup size.
in: body
required: false
type: object
backup_size: backup_size:
description: | description: |
Size of the backup, the unit is GB. Size of the backup, the unit is GB.

View File

@ -276,3 +276,21 @@ Create an incremental backup based on a parent backup:
| status | NEW | | status | NEW |
| updated | 2014-03-19T14:09:13 | | updated | 2014-03-19T14:09:13 |
+-------------+--------------------------------------+ +-------------+--------------------------------------+
Restore backup from other regions
---------------------------------
Restoring backup from other regions were introduced in Wallaby,
In multi-region deployment with geo-replicated Swift, the user is able to
create a backup in one region using the backup data created in the others,
which is useful in Disaster Recovery scenario. Instance ID is not required in
this case when restoring backup, but the original backup data location (a swift
object URL), the local datastore version and the backup data size are required.
.. warning::
The restored backup is dependent on the original backup data, if the
original backup is deleted, the restored backup is invalid.
TODO: Add CLI example once supported in python-troveclient.

View File

@ -0,0 +1,5 @@
---
features:
- In multi-region deployment with geo-replicated Swift, the user can restore
a backup in one region by manually specifying the original backup data
location created in another region.

View File

@ -22,7 +22,9 @@ from swiftclient.client import ClientException
from trove.backup.state import BackupState from trove.backup.state import BackupState
from trove.common import cfg from trove.common import cfg
from trove.common import clients from trove.common import clients
from trove.common import constants
from trove.common import exception from trove.common import exception
from trove.common import swift
from trove.common import utils from trove.common import utils
from trove.common.i18n import _ from trove.common.i18n import _
from trove.datastore import models as datastore_models from trove.datastore import models as datastore_models
@ -49,7 +51,8 @@ class Backup(object):
@classmethod @classmethod
def create(cls, context, instance, name, description=None, def create(cls, context, instance, name, description=None,
parent_id=None, incremental=False, swift_container=None): parent_id=None, incremental=False, swift_container=None,
restore_from=None):
""" """
create db record for Backup create db record for Backup
:param cls: :param cls:
@ -59,31 +62,61 @@ class Backup(object):
:param description: :param description:
:param parent_id: :param parent_id:
:param incremental: flag to indicate incremental backup :param incremental: flag to indicate incremental backup
based on previous backup based on previous backup
:param swift_container: Swift container name. :param swift_container: Swift container name.
:param restore_from: A dict that contains backup information of another
region.
:return: :return:
""" """
backup_state = BackupState.NEW
checksum = None
instance_id = None
parent = None
last_backup_id = None
location = None
backup_type = constants.BACKUP_TYPE_FULL
size = None
def _create_resources(): if restore_from:
# parse the ID from the Ref # Check location and datastore version.
LOG.info(f"Restoring backup, restore_from: {restore_from}")
backup_state = BackupState.RESTORED
ds_version_id = restore_from.get('local_datastore_version_id')
ds_version = datastore_models.DatastoreVersion.load_by_uuid(
ds_version_id)
location = restore_from.get('remote_location')
swift_client = clients.create_swift_client(context)
try:
obj_meta = swift.get_metadata(swift_client, location,
extra_attrs=['etag'])
except Exception:
msg = f'Failed to restore backup from {location}'
LOG.exception(msg)
raise exception.BackupCreationError(msg)
checksum = obj_meta['etag']
if 'parent_location' in obj_meta:
backup_type = constants.BACKUP_TYPE_INC
size = restore_from['size']
else:
instance_id = utils.get_id_from_href(instance) instance_id = utils.get_id_from_href(instance)
# Import here to avoid circular imports.
# verify that the instance exists and can perform actions from trove.instance import models as inst_model
from trove.instance.models import Instance instance_model = inst_model.Instance.load(context, instance_id)
instance_model = Instance.load(context, instance_id)
instance_model.validate_can_perform_action() instance_model.validate_can_perform_action()
cls.validate_can_perform_action(
instance_model, 'backup_create')
cls.verify_swift_auth_token(context)
if instance_model.cluster_id is not None: if instance_model.cluster_id is not None:
raise exception.ClusterInstanceOperationNotSupported() raise exception.ClusterInstanceOperationNotSupported()
cls.validate_can_perform_action(instance_model, 'backup_create')
cls.verify_swift_auth_token(context)
ds = instance_model.datastore ds = instance_model.datastore
ds_version = instance_model.datastore_version ds_version = instance_model.datastore_version
parent = None
last_backup_id = None
if parent_id: if parent_id:
# Look up the parent info or fail early if not found or if # Look up the parent info or fail early if not found or if
# the user does not have access to the parent. # the user does not have access to the parent.
@ -100,36 +133,53 @@ class Backup(object):
'checksum': _parent.checksum 'checksum': _parent.checksum
} }
last_backup_id = _parent.id last_backup_id = _parent.id
if parent:
backup_type = constants.BACKUP_TYPE_INC
def _create_resources():
try: try:
db_info = DBBackup.create(name=name, db_info = DBBackup.create(
description=description, name=name,
tenant_id=context.project_id, description=description,
state=BackupState.NEW, tenant_id=context.project_id,
instance_id=instance_id, state=backup_state,
parent_id=parent_id or instance_id=instance_id,
last_backup_id, parent_id=parent_id or last_backup_id,
datastore_version_id=ds_version.id, datastore_version_id=ds_version.id,
deleted=False) deleted=False,
location=location,
checksum=checksum,
backup_type=backup_type,
size=size
)
except exception.InvalidModelError as ex: except exception.InvalidModelError as ex:
LOG.exception("Unable to create backup record for " LOG.exception("Unable to create backup record for "
"instance: %s", instance_id) "instance: %s", instance_id)
raise exception.BackupCreationError(str(ex)) raise exception.BackupCreationError(str(ex))
backup_info = {'id': db_info.id, if not restore_from:
'name': name, backup_info = {
'description': description, 'id': db_info.id,
'instance_id': instance_id, 'name': name,
'backup_type': db_info.backup_type, 'description': description,
'checksum': db_info.checksum, 'instance_id': instance_id,
'parent': parent, 'backup_type': db_info.backup_type,
'datastore': ds.name, 'checksum': db_info.checksum,
'datastore_version': ds_version.name, 'parent': parent,
'swift_container': swift_container 'datastore': ds.name,
} 'datastore_version': ds_version.name,
api.API(context).create_backup(backup_info, instance_id) 'swift_container': swift_container
}
api.API(context).create_backup(backup_info, instance_id)
else:
context.notification.payload.update(
{'backup_id': db_info.id}
)
return db_info return db_info
return run_with_quotas(context.project_id,
{'backups': 1}, return run_with_quotas(context.project_id, {'backups': 1},
_create_resources) _create_resources)
@classmethod @classmethod
@ -372,7 +422,7 @@ class DBBackup(DatabaseModelBase):
@property @property
def is_done_successfuly(self): def is_done_successfuly(self):
return self.state == BackupState.COMPLETED return self.state in [BackupState.COMPLETED, BackupState.RESTORED]
@property @property
def filename(self): def filename(self):

View File

@ -80,27 +80,32 @@ class BackupController(wsgi.Controller):
context = req.environ[wsgi.CONTEXT_KEY] context = req.environ[wsgi.CONTEXT_KEY]
policy.authorize_on_tenant(context, 'backup:create') policy.authorize_on_tenant(context, 'backup:create')
data = body['backup'] data = body['backup']
instance = data['instance'] instance = data.get('instance')
name = data['name'] name = data['name']
desc = data.get('description') desc = data.get('description')
parent = data.get('parent_id') parent = data.get('parent_id')
incremental = data.get('incremental') incremental = data.get('incremental')
swift_container = data.get('swift_container') swift_container = data.get('swift_container')
restore_from = data.get('restore_from')
context.notification = notification.DBaaSBackupCreate(context, context.notification = notification.DBaaSBackupCreate(
request=req) context, request=req)
if not swift_container: if not restore_from:
instance_id = utils.get_id_from_href(instance) if not instance:
backup_strategy = BackupStrategy.get(context, instance_id) raise exception.BackupCreationError('instance is missing.')
if backup_strategy: if not swift_container:
swift_container = backup_strategy.swift_container instance_id = utils.get_id_from_href(instance)
backup_strategy = BackupStrategy.get(context, instance_id)
if backup_strategy:
swift_container = backup_strategy.swift_container
with StartNotification(context, name=name, instance_id=instance, with StartNotification(context, name=name, instance_id=instance,
description=desc, parent_id=parent): description=desc, parent_id=parent):
backup = Backup.create(context, instance, name, desc, backup = Backup.create(context, instance, name, desc,
parent_id=parent, incremental=incremental, parent_id=parent, incremental=incremental,
swift_container=swift_container) swift_container=swift_container,
restore_from=restore_from)
return wsgi.Result(views.BackupView(backup).data(), 202) return wsgi.Result(views.BackupView(backup).data(), 202)

View File

@ -21,6 +21,7 @@ class BackupState(object):
SAVING = "SAVING" SAVING = "SAVING"
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
FAILED = "FAILED" FAILED = "FAILED"
RESTORED = "RESTORED"
DELETE_FAILED = "DELETE_FAILED" DELETE_FAILED = "DELETE_FAILED"
RUNNING_STATES = [NEW, BUILDING, SAVING] RUNNING_STATES = [NEW, BUILDING, SAVING]
END_STATES = [COMPLETED, FAILED, DELETE_FAILED] END_STATES = [COMPLETED, FAILED, DELETE_FAILED, RESTORED]

View File

@ -652,14 +652,27 @@ backup = {
"properties": { "properties": {
"backup": { "backup": {
"type": "object", "type": "object",
"required": ["instance", "name"], "required": ["name"],
"properties": { "properties": {
"description": non_empty_string, "description": non_empty_string,
"instance": uuid, "instance": uuid,
"name": non_empty_string, "name": non_empty_string,
"parent_id": uuid, "parent_id": uuid,
"incremental": boolean_string, "incremental": boolean_string,
"swift_container": non_empty_string "swift_container": non_empty_string,
"restore_from": {
"type": "object",
"required": [
"remote_location",
"local_datastore_version_id",
"size"
],
"properties": {
"remote_location": non_empty_string,
"local_datastore_version_id": uuid,
"size": {"type": "number"}
}
}
} }
} }
} }

16
trove/common/constants.py Normal file
View File

@ -0,0 +1,16 @@
# Copyright 2021 Catalyst Cloud Ltd.
#
# 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.
BACKUP_TYPE_FULL = 'full'
BACKUP_TYPE_INC = 'incremental'

42
trove/common/swift.py Normal file
View File

@ -0,0 +1,42 @@
# Copyright 2021 Catalyst Cloud Ltd.
#
# 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.
def parse_location(location):
storage_url = "/".join(location.split('/')[:-2])
container_name = location.split('/')[-2]
object_name = location.split('/')[-1]
return storage_url, container_name, object_name
def _get_attr(original):
"""Get a friendly name from an object header key."""
key = original.replace('-', '_')
key = key.replace('x_object_meta_', '')
return key
def get_metadata(client, location, extra_attrs=[]):
_, container_name, object_name = parse_location(location)
headers = client.head_object(container_name, object_name)
meta = {}
for key, value in headers.items():
if key.startswith('x-object-meta'):
meta[_get_attr(key)] = value
for key in extra_attrs:
meta[key] = headers.get(key)
return meta

View File

@ -351,10 +351,10 @@ class SimpleInstance(object):
- Then server status - Then server status
- Otherwise, unknown - Otherwise, unknown
""" """
LOG.info(f"Getting instance status for {self.id}, " LOG.debug(f"Getting instance status for {self.id}, "
f"task status: {self.db_info.task_status}, " f"task status: {self.db_info.task_status}, "
f"datastore status: {self.datastore_status.status}, " f"datastore status: {self.datastore_status.status}, "
f"server status: {self.db_info.server_status}") f"server status: {self.db_info.server_status}")
task_status = self.db_info.task_status task_status = self.db_info.task_status
server_status = self.db_info.server_status server_status = self.db_info.server_status

View File

@ -1470,8 +1470,7 @@ class BackupTasks(object):
def _delete(backup): def _delete(backup):
backup.deleted = True backup.deleted = True
backup.deleted_at = timeutils.utcnow() backup.deleted_at = timeutils.utcnow()
# Set datastore_version_id to None so that datastore_version could # Set datastore_version_id to None to remove dependency.
# be deleted.
backup.datastore_version_id = None backup.datastore_version_id = None
backup.save() backup.save()
@ -1479,7 +1478,9 @@ class BackupTasks(object):
backup = bkup_models.Backup.get_by_id(context, backup_id) backup = bkup_models.Backup.get_by_id(context, backup_id)
try: try:
filename = backup.filename filename = backup.filename
if filename: # Do not remove the object if the backup was restored from remote
# location.
if filename and backup.state != bkup_models.BackupState.RESTORED:
BackupTasks.delete_files_from_swift(context, BackupTasks.delete_files_from_swift(context,
backup.container_name, backup.container_name,
filename) filename)

View File

@ -176,7 +176,7 @@ class BackupCreateTest(trove_testtools.TestCase):
BACKUP_NAME, BACKUP_DESC) BACKUP_NAME, BACKUP_DESC)
def test_create_backup_swift_token_invalid(self): def test_create_backup_swift_token_invalid(self):
instance = MagicMock() instance = MagicMock(cluster_id=None)
with patch.object(instance_models.BuiltInstance, 'load', with patch.object(instance_models.BuiltInstance, 'load',
return_value=instance): return_value=instance):
instance.validate_can_perform_action = MagicMock( instance.validate_can_perform_action = MagicMock(
@ -191,7 +191,7 @@ class BackupCreateTest(trove_testtools.TestCase):
BACKUP_NAME, BACKUP_DESC) BACKUP_NAME, BACKUP_DESC)
def test_create_backup_datastore_operation_not_supported(self): def test_create_backup_datastore_operation_not_supported(self):
instance = MagicMock() instance = MagicMock(cluster_id=None)
with patch.object(instance_models.BuiltInstance, 'load', with patch.object(instance_models.BuiltInstance, 'load',
return_value=instance): return_value=instance):
with patch.object( with patch.object(

View File

@ -0,0 +1,84 @@
# Copyright 2021 Catalyst Cloud
#
# 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 unittest import mock
from trove.backup import service
from trove.backup.state import BackupState
from trove.common import context
from trove.common import wsgi
from trove.datastore import models as ds_models
from trove.tests.unittests import trove_testtools
from trove.tests.unittests.util import util
class TestBackupController(trove_testtools.TestCase):
@classmethod
def setUpClass(cls):
util.init_db()
cls.ds_name = cls.random_name('datastore',
prefix='TestBackupController')
ds_models.update_datastore(name=cls.ds_name, default_version=None)
cls.ds = ds_models.Datastore.load(cls.ds_name)
ds_models.update_datastore_version(
cls.ds_name, 'fake-ds-version', 'mysql', '', ['trove', 'mysql'],
'', 1)
cls.ds_version = ds_models.DatastoreVersion.load(
cls.ds, 'fake-ds-version')
cls.controller = service.BackupController()
super(TestBackupController, cls).setUpClass()
@classmethod
def tearDownClass(cls):
util.cleanup_db()
super(TestBackupController, cls).tearDownClass()
def setUp(self):
trove_testtools.patch_notifier(self)
self.context = context.TroveContext(project_id=self.random_uuid())
super(TestBackupController, self).setUp()
@mock.patch('trove.common.clients.create_swift_client')
def test_create_restore_from(self, mock_swift_client):
swift_client = mock.MagicMock()
swift_client.head_object.return_value = {'etag': 'fake-etag'}
mock_swift_client.return_value = swift_client
req = mock.MagicMock(environ={wsgi.CONTEXT_KEY: self.context})
name = self.random_name(
name='backup', prefix='TestBackupController')
body = {
'backup': {
"name": name,
"restore_from": {
"remote_location": "http://192.168.206.8:8080/v1/"
"AUTH_055b2fb9a2264ae5a5f6b3cc066c4a1d/"
"fake-container/fake-object",
"local_datastore_version_id": self.ds_version.id,
"size": 0.2
}
}
}
ret = self.controller.create(req, body, self.context.project_id)
self.assertEqual(202, ret.status)
ret_backup = ret.data(None)['backup']
self.assertEqual(BackupState.RESTORED, ret_backup.get('status'))
self.assertEqual(name, ret_backup.get('name'))

View File

@ -1037,10 +1037,8 @@ class BackupTasksTest(trove_testtools.TestCase):
self.assertTrue(self.backup.deleted) self.assertTrue(self.backup.deleted)
@patch('trove.taskmanager.models.LOG')
@patch('trove.common.clients.create_swift_client') @patch('trove.common.clients.create_swift_client')
def test_delete_backup_fail_delete_manifest(self, mock_swift_client, def test_delete_backup_fail_delete_manifest(self, mock_swift_client):
mock_logging):
client_mock = MagicMock() client_mock = MagicMock()
client_mock.head_object.return_value = {} client_mock.head_object.return_value = {}
client_mock.delete_object.side_effect = ClientException("foo") client_mock.delete_object.side_effect = ClientException("foo")
@ -1070,6 +1068,12 @@ class BackupTasksTest(trove_testtools.TestCase):
client_mock.delete_object.assert_called_once_with('container', client_mock.delete_object.assert_called_once_with('container',
'12e48.xbstream.gz') '12e48.xbstream.gz')
def test_delete_backup_restored(self):
self.backup.state = state.BackupState.RESTORED
taskmanager_models.BackupTasks.delete_backup(mock.ANY, self.backup.id)
self.assertTrue(self.backup.deleted)
def test_parse_manifest(self): def test_parse_manifest(self):
manifest = 'container/prefix' manifest = 'container/prefix'
cont, prefix = taskmanager_models.BackupTasks._parse_manifest(manifest) cont, prefix = taskmanager_models.BackupTasks._parse_manifest(manifest)