Add support for trove incremental backups

Added extra column to backups tables (under instances
and under backups) that will show Yes / No if the
backup is incremental (ie it has a parent)

When you drill down into backup details we now display
the name of the backup parent if this is an
incremental backup. This is displayed as a link with
the backup name so it is navigable

When you create a new backup you can now specify
an existing backup to use as the parent

Note: At some point we should probably have the
create backup dialog filter the available parents
based on the current selection for the instance.
However, so far I haven't worked out how to make
the backup parent choiceField change dynamically
based on user updates to the instance choiceField

Change-Id: I8bc70805aae50a019da3915f8e7aff25f6a7bbc6
Implements: blueprint trove-incremental-backup
This commit is contained in:
Andrew Bramley 2014-07-14 15:56:39 -04:00
parent 96ee29c2e3
commit e34fd6758e
9 changed files with 173 additions and 17 deletions

View File

@ -108,8 +108,9 @@ def backup_delete(request, backup_id):
return troveclient(request).backups.delete(backup_id)
def backup_create(request, name, instance_id, description=None):
return troveclient(request).backups.create(name, instance_id, description)
def backup_create(request, name, instance_id, description=None, parent_id=None):
return troveclient(request).backups.create(name, instance_id,
description, parent_id)
def flavor_list(request):

View File

@ -13,7 +13,7 @@
# under the License.
from django.core.urlresolvers import reverse
from django.template.defaultfilters import title # noqa
from django.template import defaultfilters as d_filters
from django.utils.translation import ugettext_lazy as _
from horizon import tables
@ -123,6 +123,10 @@ def get_datastore_version(obj):
return _("Not available")
def is_incremental(obj):
return hasattr(obj, 'parent_id') and obj.parent_id is not None
class BackupsTable(tables.DataTable):
name = tables.Column("name",
link="horizon:project:database_backups:detail",
@ -135,8 +139,13 @@ class BackupsTable(tables.DataTable):
filters=[filters.parse_isotime])
instance = tables.Column(db_name, link=db_link,
verbose_name=_("Database"))
incremental = tables.Column(is_incremental,
verbose_name=_("Incremental"),
filters=(d_filters.yesno,
d_filters.capfirst))
status = tables.Column("status",
filters=(title, filters.replace_underscores),
filters=(d_filters.title,
filters.replace_underscores),
verbose_name=_("Status"),
status=True,
status_choices=STATUS_CHOICES)

View File

@ -1,3 +1,4 @@
{% load i18n %}
<p>{% blocktrans %}Specify the details for the backup.{% endblocktrans %}</p>
<p>{% blocktrans %}Specify the details for the database backup.{% endblocktrans %}</p>
<p>{% blocktrans %}You can perform an incremental backup by specifying a parent backup. <strong>However,</strong> not all databases support incremental backups in which case this operation will result in an error.{% endblocktrans %}</p>

View File

@ -42,6 +42,20 @@
</dl>
</div>
{% if backup.parent %}
<div class="status row-fluid detail">
<h4>{% trans "Incremental Backup" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Parent Backup" %}</dt>
<dd>
{% url 'horizon:project:database_backups:detail' backup.parent.id as parent_url %}
<a href="{{ parent_url }}">{{ backup.parent.name }}</a>
</dd>
</dl>
</div>
{% endif %}
{% if instance %}
<div class="addresses row-fluid detail">
<h4>{% trans "Database Info" %}</h4>

View File

@ -55,21 +55,45 @@ class DatabasesBackupsTests(test.TestCase):
self.assertEqual(res.status_code, 200)
self.assertMessageCount(res, error=1)
@test.create_stubs({api.trove: ('instance_list',)})
@test.create_stubs({api.trove: ('instance_list',
'backup_list',
'backup_create')})
def test_launch_backup(self):
api.trove.instance_list(IsA(http.HttpRequest))\
.AndReturn([])
.AndReturn(self.databases.list())
api.trove.backup_list(IsA(http.HttpRequest)) \
.AndReturn(self.database_backups.list())
database = self.databases.first()
backupName = "NewBackup"
backupDesc = "Backup Description"
api.trove.backup_create(
IsA(http.HttpRequest),
backupName,
database.id,
backupDesc,
"")
self.mox.ReplayAll()
res = self.client.get(BACKUP_URL)
self.assertTemplateUsed(res,
'project/database_backups/backup.html')
post = {
'name': backupName,
'instance': database.id,
'description': backupDesc,
'parent': ""
}
res = self.client.post(BACKUP_URL, post)
@test.create_stubs({api.trove: ('instance_list',)})
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({api.trove: ('instance_list', 'backup_list')})
def test_launch_backup_exception(self):
api.trove.instance_list(IsA(http.HttpRequest))\
.AndRaise(self.exceptions.trove)
api.trove.backup_list(IsA(http.HttpRequest)) \
.AndReturn(self.database_backups.list())
self.mox.ReplayAll()
@ -78,6 +102,40 @@ class DatabasesBackupsTests(test.TestCase):
self.assertTemplateUsed(res,
'project/database_backups/backup.html')
@test.create_stubs({api.trove: ('instance_list',
'backup_list',
'backup_create')})
def test_launch_backup_incr(self):
api.trove.instance_list(IsA(http.HttpRequest)) \
.AndReturn(self.databases.list())
api.trove.backup_list(IsA(http.HttpRequest)) \
.AndReturn(self.database_backups.list())
database = self.databases.first()
backupName = "NewBackup"
backupDesc = "Backup Description"
backupParent = self.database_backups.first()
api.trove.backup_create(
IsA(http.HttpRequest),
backupName,
database.id,
backupDesc,
backupParent.id)
self.mox.ReplayAll()
post = {
'name': backupName,
'instance': database.id,
'description': backupDesc,
'parent': backupParent.id,
}
res = self.client.post(BACKUP_URL, post)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({api.trove: ('backup_get', 'instance_get')})
def test_detail_backup(self):
api.trove.backup_get(IsA(http.HttpRequest),
@ -104,3 +162,21 @@ class DatabasesBackupsTests(test.TestCase):
res = self.client.get(DETAILS_URL)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({api.trove: ('backup_get', 'instance_get')})
def test_detail_backup_incr(self):
incr_backup = self.database_backups.list()[2]
parent_backup = self.database_backups.list()[1]
api.trove.backup_get(IsA(http.HttpRequest), IsA(unicode))\
.AndReturn(incr_backup)
api.trove.backup_get(IsA(http.HttpRequest), incr_backup.parent_id) \
.AndReturn(parent_backup)
api.trove.instance_get(IsA(http.HttpRequest), IsA(str))\
.AndReturn(self.databases.list()[1])
self.mox.ReplayAll()
url = reverse('horizon:project:database_backups:detail',
args=[incr_backup.id])
res = self.client.get(url)
self.assertTemplateUsed(res, 'project/database_backups/details.html')

View File

@ -87,6 +87,16 @@ class DetailView(horizon_views.APIView):
redirect = reverse('horizon:project:database_backups:index')
msg = _('Unable to retrieve details for backup: %s') % backup_id
exceptions.handle(self.request, msg, redirect=redirect)
try:
if(hasattr(backup, 'parent_id') and backup.parent_id is not None):
backup.parent = api.trove.backup_get(request, backup.parent_id)
except Exception:
redirect = reverse('horizon:project:database_backups:index')
msg = _('Unable to retrieve details for parent backup: %s') % \
backup.parent_id
exceptions.handle(self.request, msg, redirect=redirect)
try:
instance = api.trove.instance_get(request, backup.instance_id)
except Exception:

View File

@ -35,6 +35,9 @@ class BackupDetailsAction(workflows.Action):
widget=forms.TextInput(),
required=False,
help_text=_("Optional Backup Description"))
parent = forms.ChoiceField(label=_("Parent Backup"),
required=False,
help_text=_("Optional parent backup"))
class Meta:
name = _("Details")
@ -47,15 +50,31 @@ class BackupDetailsAction(workflows.Action):
instances = api.trove.instance_list(request)
except Exception:
instances = []
msg = _("Unable to list database instance to backup.")
msg = _("Unable to list database instances to backup.")
exceptions.handle(request, msg)
return [(i.id, i.name) for i in instances
if i.status in project_tables.ACTIVE_STATES]
def populate_parent_choices(self, request, context):
try:
backups = api.trove.backup_list(request)
choices = [(b.id, b.name) for b in backups
if b.status == 'COMPLETED']
except Exception:
choices = []
msg = _("Unable to list database backups for parent.")
exceptions.handle(request, msg)
if choices:
choices.insert(0, ("", _("Select parent backup")))
else:
choices.insert(0, ("", _("No backups available")))
return choices
class SetBackupDetails(workflows.Step):
action_class = BackupDetailsAction
contributes = ["name", "description", "instance"]
contributes = ["name", "description", "instance", "parent"]
class CreateBackup(workflows.Workflow):
@ -81,7 +100,8 @@ class CreateBackup(workflows.Workflow):
api.trove.backup_create(request,
context['name'],
context['instance'],
context['description'])
context['description'],
context['parent'])
return True
except Exception:
LOG.exception("Exception while creating backup")

View File

@ -13,7 +13,7 @@
# under the License.
from django.core import urlresolvers
from django.template.defaultfilters import title # noqa
from django.template import defaultfilters as d_filters
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
@ -197,7 +197,8 @@ class InstancesTable(tables.DataTable):
verbose_name=_("Volume Size"),
attrs={'data-type': 'size'})
status = tables.Column("status",
filters=(title, filters.replace_underscores),
filters=(d_filters.title,
filters.replace_underscores),
verbose_name=_("Status"),
status=True,
status_choices=STATUS_CHOICES)
@ -242,6 +243,10 @@ class DatabaseTable(tables.DataTable):
return datum.name
def is_incremental(obj):
return hasattr(obj, 'parent_id') and obj.parent_id is not None
class InstanceBackupsTable(tables.DataTable):
name = tables.Column("name",
link=("horizon:project:database_backups:detail"),
@ -251,8 +256,13 @@ class InstanceBackupsTable(tables.DataTable):
location = tables.Column(lambda obj: _("Download"),
link=lambda obj: obj.locationRef,
verbose_name=_("Backup File"))
incremental = tables.Column(is_incremental,
verbose_name=_("Incremental"),
filters=(d_filters.yesno,
d_filters.capfirst))
status = tables.Column("status",
filters=(title, filters.replace_underscores),
filters=(d_filters.title,
filters.replace_underscores),
verbose_name=_("Status"),
status=True,
status_choices=backup_tables.STATUS_CHOICES)

View File

@ -93,6 +93,19 @@ BACKUP_TWO = {
}
BACKUP_TWO_INC = {
"instance_id": "4d7b3f57-44f5-41d2-8e86-36b88cad572a",
"status": "COMPLETED",
"updated": "2013-08-10T20:20:55",
"locationRef": "http://swift/v1/AUTH/database_backups/f145.tar.gz",
"name": "backup2-Incr",
"created": "2013-08-10T20:20:37",
"size": 0.13,
"id": "e4602a3c-2bca-478f-b059-b6c215510fb5",
"description": "Longer description of backup",
"parent_id": "e4602a3c-2bca-478f-b059-b6c215510fb4",
}
USER_ONE = {
"name": "Test_User",
"host": "%",
@ -144,6 +157,7 @@ def data(TEST):
DATABASE_DATA_TWO)
bkup1 = backups.Backup(backups.Backups(None), BACKUP_ONE)
bkup2 = backups.Backup(backups.Backups(None), BACKUP_TWO)
bkup3 = backups.Backup(backups.Backups(None), BACKUP_TWO_INC)
user1 = users.User(users.Users(None), USER_ONE)
user_db1 = databases.Database(databases.Databases(None),
USER_DB_ONE)
@ -166,6 +180,7 @@ def data(TEST):
TEST.databases.add(database2)
TEST.database_backups.add(bkup1)
TEST.database_backups.add(bkup2)
TEST.database_backups.add(bkup3)
TEST.database_users.add(user1)
TEST.database_user_dbs.add(user_db1)
TEST.datastores = utils.TestDataContainer()