Enable volume snapshot backups

Make it possible to use horizon for backing
up snapshots.

 * Add snapshot column to backups table
 * Add snapshot information to backup detail
 * Fix wrong template path in create_backup.html
 * Add ChoiceField with snapshots belonging
   to the volume in create backup form
 * Add create backup option in snapshots table
 * Adjust tests and test data

When backing up from snapshots table the snapshot
ChoiceField is preset with the choosen snapshot
as the only option.
Implements: blueprint volume-snapshot-backups

Change-Id: I4b7707d95756501e0622460e3ddc4e3f7624f02e
This commit is contained in:
Jesper Schmitz Mouridsen 2019-06-26 18:22:58 +00:00
parent 67ca5ad9f7
commit 9fa1cddf09
13 changed files with 291 additions and 25 deletions

View File

@ -123,9 +123,9 @@ class VolumeType(BaseCinderAPIResourceWrapper):
class VolumeBackup(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'container', 'size', 'status',
'created_at', 'volume_id', 'availability_zone']
'created_at', 'volume_id', 'availability_zone', 'snapshot_id']
_volume = None
_snapshot = None
@property
def volume(self):
return self._volume
@ -134,6 +134,14 @@ class VolumeBackup(BaseCinderAPIResourceWrapper):
def volume(self, value):
self._volume = value
@property
def snapshot(self):
return self._snapshot
@snapshot.setter
def snapshot(self, value):
self._snapshot = value
class QosSpecs(BaseCinderAPIResourceWrapper):
@ -630,7 +638,8 @@ def volume_backup_create(request,
container_name,
name,
description,
force=False):
force=False,
snapshot_id=None):
# need to ensure the container name is not an empty
# string, but pass None to get the container name
# generated correctly
@ -639,6 +648,7 @@ def volume_backup_create(request,
container=container_name if container_name else None,
name=name,
description=description,
snapshot_id=snapshot_id,
force=force)
return VolumeBackup(backup)

View File

@ -40,20 +40,58 @@ class CreateBackupForm(forms.SelfHandlingForm):
validators=[containers_utils.no_slash_validator],
required=False)
volume_id = forms.CharField(widget=forms.HiddenInput())
snapshot_id = forms.ThemableChoiceField(label=_("Backup Snapshot"),
required=False)
def __init__(self, request, *args, **kwargs):
super(CreateBackupForm, self).__init__(request, *args, **kwargs)
if kwargs['initial'].get('snapshot_id'):
snap_id = kwargs['initial']['snapshot_id']
try:
snapshot = api.cinder.volume_snapshot_get(request, snap_id)
self.fields['snapshot_id'].choices = [(snapshot.id,
snapshot.name)]
self.fields['snapshot_id'].initial = snap_id
except Exception:
redirect = reverse('horizon:project:snapshots:index')
exceptions.handle(request, _('Unable to fetch snapshot'),
redirect=redirect)
else:
try:
sop = {'volume_id': kwargs['initial']['volume_id']}
snapshots = api.cinder.volume_snapshot_list(request,
search_opts=sop)
snapshots.sort(key=operator.attrgetter('id', 'created_at'))
snapshotChoices = [[snapshot.id, snapshot.name]
for snapshot in snapshots]
if not snapshotChoices:
snapshotChoices.insert(0, ('',
_("No snapshot for this volume")))
else:
snapshotChoices.insert(
0, ('',
_("Select snapshot to backup (Optional)")))
self.fields['snapshot_id'].choices = snapshotChoices
except Exception:
redirect = reverse('horizon:project:volumes:index')
exceptions.handle(request, _('Unable to fetch snapshots'),
redirect=redirect)
def handle(self, request, data):
try:
volume = api.cinder.volume_get(request, data['volume_id'])
snapshot_id = data['snapshot_id'] or None
force = False
if volume.status == 'in-use':
force = True
backup = api.cinder.volume_backup_create(request,
data['volume_id'],
data['container_name'],
data['name'],
data['description'],
force=force)
backup = api.cinder.volume_backup_create(
request, data['volume_id'],
data['container_name'], data['name'],
data['description'], force=force,
snapshot_id=snapshot_id
)
message = _('Creating volume backup "%s"') % data['name']
messages.info(request, message)

View File

@ -44,6 +44,24 @@ class BackupVolumeNameColumn(tables.Column):
return reverse(self.link, args=(volume_id,))
class SnapshotColumn(tables.Column):
def get_raw_data(self, backup):
snapshot = backup.snapshot
if snapshot:
snapshot_name = snapshot.name
snapshot_name = html.escape(snapshot_name)
elif backup.snapshot_id:
snapshot_name = _("Unknown")
else:
return None
return safestring.mark_safe(snapshot_name)
def get_link_url(self, backup):
if backup.snapshot:
return reverse('horizon:project:snapshots:detail',
args=(backup.snapshot_id,))
class DeleteBackup(tables.DeleteAction):
help_text = _("Deleted volume backups are not recoverable.")
policy_rules = (("volume", "backup:delete"),)
@ -103,6 +121,14 @@ class UpdateRow(tables.Row):
backup.volume_id)
except Exception:
pass
if backup.snapshot_id is not None:
try:
backup.snapshot = cinder.volume_snapshot_get(
request, backup.snapshot_id)
except Exception:
pass
else:
backup.snapshot = None
return backup
@ -148,6 +174,9 @@ class BackupsTable(tables.DataTable):
volume_name = BackupVolumeNameColumn("name",
verbose_name=_("Volume Name"),
link="horizon:project:volumes:detail")
snapshot = SnapshotColumn("snapshot",
verbose_name=_("Snapshot"),
link="horizon:project:snapshots:detail")
class Meta(object):
name = "volume_backups"

View File

@ -32,8 +32,19 @@ class BackupOverviewTab(tabs.Tab):
volume = cinder.volume_get(request, backup.volume_id)
except Exception:
volume = None
try:
if backup.snapshot_id:
snapshot = cinder.volume_snapshot_get(request,
backup.snapshot_id)
else:
snapshot = None
except Exception:
snapshot = None
return {'backup': backup,
'volume': volume}
'volume': volume,
'snapshot': snapshot}
except Exception:
redirect = reverse('horizon:project:backups:index')
exceptions.handle(self.request,

View File

@ -9,6 +9,11 @@
You must have one of these services activated in order to create a backup.
{% endblocktrans %}</p>
<p>{% blocktrans %}
If a snapshot is specified here only the specified snapshot of the volume
will be backed up.
</p>
{% endblocktrans %}
<p>{% blocktrans %}
If no container name is provided, a default container named volumebackups
will be provisioned for you.
Backups will be the same size as the volume they originate from.

View File

@ -20,6 +20,20 @@
</a>
</dd>
{% endif %}
{% if backup.snapshot_id %}
<dt>{% trans "Snapshot" %}</dt>
{% if snapshot %}
<dd>
<a href="{% url 'horizon:project:snapshots:detail' backup.snapshot_id %}">
{{ snapshot.name }}
</a>
</dd>
{% elif backup.snapshot_id %}
<dd>
{{ backup.snapshot_id }}
{% endif %}
</dd>
{% endif %}
</dl>
<h4>{% trans "Specs" %}</h4>

View File

@ -3,5 +3,5 @@
{% block title %}{% trans "Create Volume Backup" %}{% endblock %}
{% block main %}
{% include 'project/volumes/backups/_create_backup.html' %}
{% include 'project/backups/_create_backup.html' %}
{% endblock %}

View File

@ -27,13 +27,15 @@ INDEX_URL = reverse('horizon:project:backups:index')
class VolumeBackupsViewTests(test.TestCase):
@test.create_mocks({api.cinder: ('volume_list',
@test.create_mocks({api.cinder: ('volume_list', 'volume_snapshot_list',
'volume_backup_list_paged')})
def _test_backups_index_paginated(self, marker, sort_dir, backups, url,
has_more, has_prev):
self.mock_volume_backup_list_paged.return_value = [backups,
has_more, has_prev]
self.mock_volume_list.return_value = self.cinder_volumes.list()
self.mock_volume_snapshot_list.return_value \
= self.cinder_volume_snapshots.list()
res = self.client.get(urlunquote(url))
@ -43,12 +45,14 @@ class VolumeBackupsViewTests(test.TestCase):
test.IsHttpRequest(), marker=marker, sort_dir=sort_dir,
paginate=True)
self.mock_volume_list.assert_called_once_with(test.IsHttpRequest())
self.mock_volume_snapshot_list.assert_called_once_with(
test.IsHttpRequest())
return res
@override_settings(API_RESULT_PAGE_SIZE=1)
def test_backups_index_paginated(self):
backups = self.cinder_volume_backups.list()
expected_snapshosts = self.cinder_volume_snapshots.list()
size = settings.API_RESULT_PAGE_SIZE
base_url = INDEX_URL
next = backup_tables.BackupsTable._meta.pagination_param
@ -71,7 +75,7 @@ class VolumeBackupsViewTests(test.TestCase):
has_more=True, has_prev=True)
result = res.context['volume_backups_table'].data
self.assertItemsEqual(result, expected_backups)
self.assertEqual(result[0].snapshot.id, expected_snapshosts[1].id)
# get last page
expected_backups = backups[-size:]
marker = expected_backups[0].id
@ -110,6 +114,7 @@ class VolumeBackupsViewTests(test.TestCase):
self.assertItemsEqual(result, expected_backups)
@test.create_mocks({api.cinder: ('volume_backup_create',
'volume_snapshot_list',
'volume_get')})
def test_create_backup_available(self):
volume = self.cinder_volumes.first()
@ -131,7 +136,9 @@ class VolumeBackupsViewTests(test.TestCase):
self.assertNoFormErrors(res)
self.assertMessageCount(error=0, warning=0)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_volume_snapshot_list.assert_called_once_with(
test.IsHttpRequest(),
search_opts={'volume_id': volume.id})
self.mock_volume_get.assert_called_once_with(test.IsHttpRequest(),
volume.id)
self.mock_volume_backup_create.assert_called_once_with(
@ -140,18 +147,97 @@ class VolumeBackupsViewTests(test.TestCase):
backup.container_name,
backup.name,
backup.description,
force=False)
force=False,
snapshot_id=None)
@test.create_mocks({api.cinder: ('volume_backup_create',
'volume_get')})
@test.create_mocks(
{api.cinder: ('volume_backup_create', 'volume_snapshot_get',
'volume_get')})
def test_create_backup_from_snapshot_table(self):
backup = self.cinder_volume_backups.list()[1]
volume = self.cinder_volumes.list()[4]
snapshot = self.cinder_volume_snapshots.list()[1]
self.mock_volume_backup_create.return_value = backup
self.mock_volume_get.return_value = volume
self.mock_volume_snapshot_get.return_value = snapshot
formData = {'method': 'CreateBackupForm',
'tenant_id': self.tenant.id,
'volume_id': volume.id,
'container_name': backup.container_name,
'name': backup.name,
'snapshot_id': backup.snapshot_id,
'description': backup.description}
url = reverse('horizon:project:volumes:create_snapshot_backup',
args=[backup.volume_id, backup.snapshot_id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertMessageCount(error=0, warning=0)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_volume_get.assert_called_once_with(test.IsHttpRequest(),
backup.volume_id)
self.mock_volume_backup_create.assert_called_once_with(
test.IsHttpRequest(),
backup.volume_id,
backup.container_name,
backup.name,
backup.description,
force=False,
snapshot_id=backup.snapshot_id)
@test.create_mocks(
{api.cinder: ('volume_backup_create',
'volume_snapshot_list',
'volume_get')})
def test_create_backup_from_snapshot_volume_table(self):
volume = self.cinder_volumes.list()[4]
backup = self.cinder_volume_backups.list()[1]
snapshots = self.cinder_volume_snapshots.list()[1:3]
self.mock_volume_backup_create.return_value = backup
self.mock_volume_get.return_value = volume
self.mock_volume_snapshot_list.return_value = snapshots
formData = {'method': 'CreateBackupForm',
'tenant_id': self.tenant.id,
'volume_id': volume.id,
'container_name': backup.container_name,
'name': backup.name,
'snapshot_id': snapshots[0].id,
'description': backup.description}
url = reverse('horizon:project:volumes:create_backup',
args=[backup.volume_id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.mock_volume_snapshot_list.assert_called_once_with(
test.IsHttpRequest(),
search_opts={'volume_id': volume.id})
self.assertMessageCount(error=0, warning=0)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_volume_get.assert_called_once_with(test.IsHttpRequest(),
backup.volume_id)
self.mock_volume_backup_create.assert_called_once_with(
test.IsHttpRequest(),
backup.volume_id,
backup.container_name,
backup.name,
backup.description,
force=False,
snapshot_id=backup.snapshot_id)
@test.create_mocks(
{api.cinder: ('volume_backup_create', 'volume_snapshot_list',
'volume_get')})
def test_create_backup_in_use(self):
# The third volume in the cinder test volume data is in-use
volume = self.cinder_volumes.list()[2]
backup = self.cinder_volume_backups.first()
backup = self.cinder_volume_backups.first()
snapshots = []
self.mock_volume_get.return_value = volume
self.mock_volume_backup_create.return_value = backup
self.mock_volume_snapshot_list.return_value = snapshots
formData = {'method': 'CreateBackupForm',
'tenant_id': self.tenant.id,
'volume_id': volume.id,
@ -160,8 +246,11 @@ class VolumeBackupsViewTests(test.TestCase):
'description': backup.description}
url = reverse('horizon:project:volumes:create_backup',
args=[volume.id])
res = self.client.post(url, formData)
res = self.client.post(url, formData)
self.mock_volume_snapshot_list.assert_called_once_with(
test.IsHttpRequest(),
search_opts={'volume_id': volume.id})
self.assertNoFormErrors(res)
self.assertMessageCount(error=0, warning=0)
self.assertRedirectsNoFollow(res, INDEX_URL)
@ -173,21 +262,24 @@ class VolumeBackupsViewTests(test.TestCase):
backup.container_name,
backup.name,
backup.description,
force=True)
force=True,
snapshot_id=None)
@test.create_mocks({api.cinder: ('volume_list',
'volume_snapshot_list',
'volume_backup_list_paged',
'volume_backup_delete')})
def test_delete_volume_backup(self):
vol_backups = self.cinder_volume_backups.list()
volumes = self.cinder_volumes.list()
backup = self.cinder_volume_backups.first()
snapshots = self.cinder_volume_snapshots.list()
self.mock_volume_backup_list_paged.return_value = [vol_backups,
False, False]
self.mock_volume_list.return_value = volumes
self.mock_volume_backup_delete.return_value = None
self.mock_volume_snapshot_list.return_value = snapshots
formData = {'action':
'volume_backups__delete__%s' % backup.id}
res = self.client.post(INDEX_URL, formData)
@ -198,6 +290,8 @@ class VolumeBackupsViewTests(test.TestCase):
test.IsHttpRequest(), marker=None, sort_dir='desc',
paginate=True)
self.mock_volume_list.assert_called_once_with(test.IsHttpRequest())
self.mock_volume_snapshot_list.assert_called_once_with(
test.IsHttpRequest())
self.mock_volume_backup_delete.assert_called_once_with(
test.IsHttpRequest(), backup.id)
@ -221,6 +315,31 @@ class VolumeBackupsViewTests(test.TestCase):
self.mock_volume_get.assert_called_once_with(
test.IsHttpRequest(), backup.volume_id)
@test.create_mocks({api.cinder: ('volume_backup_get',
'volume_snapshot_get',
'volume_get')})
def test_volume_backup_detail_get_with_snapshot(self):
backup = self.cinder_volume_backups.list()[1]
volume = self.cinder_volumes.get(id=backup.volume_id)
self.mock_volume_backup_get.return_value = backup
self.mock_volume_get.return_value = volume
self.mock_volume_snapshot_get.return_value \
= self.cinder_volume_snapshots.list()[1]
url = reverse('horizon:project:backups:detail',
args=[backup.id])
res = self.client.get(url)
self.assertTemplateUsed(res, 'horizon/common/_detail.html')
self.assertEqual(res.context['backup'].id, backup.id)
self.assertEqual(res.context['snapshot'].id, backup.snapshot_id)
self.mock_volume_backup_get.assert_called_once_with(
test.IsHttpRequest(), backup.id)
self.mock_volume_get.assert_called_once_with(
test.IsHttpRequest(), backup.volume_id)
self.mock_volume_snapshot_get.assert_called_once_with(
test.IsHttpRequest(), backup.snapshot_id)
@test.create_mocks({api.cinder: ('volume_backup_get',)})
def test_volume_backup_detail_get_with_exception(self):
# Test to verify redirect if get volume backup fails

View File

@ -48,8 +48,11 @@ class BackupsView(tables.DataTableView, tables.PagedTableMixin,
paginate=True)
volumes = api.cinder.volume_list(self.request)
volumes = dict((v.id, v) for v in volumes)
snapshots = api.cinder.volume_snapshot_list(self.request)
snapshots = dict((s.id, s) for s in snapshots)
for backup in backups:
backup.volume = volumes.get(backup.volume_id)
backup.snapshot = snapshots.get(backup.snapshot_id)
except Exception:
backups = []
exceptions.handle(self.request, _("Unable to retrieve "
@ -73,6 +76,9 @@ class CreateBackupView(forms.ModalFormView):
return context
def get_initial(self):
if self.kwargs.get('snapshot_id'):
return {"volume_id": self.kwargs["volume_id"],
"snapshot_id": self.kwargs["snapshot_id"]}
return {"volume_id": self.kwargs["volume_id"]}

View File

@ -101,6 +101,8 @@ class DeleteVolumeSnapshot(policy.PolicyTargetMixin, tables.DeleteAction):
# Can't delete snapshot if part of group snapshot
if getattr(datum, 'group_snapshot_id', None):
return False
if datum.status == 'backing-up':
return False
return True
@ -142,6 +144,23 @@ class CreateVolumeFromSnapshot(tables.LinkAction):
return False
class CreateBackup(policy.PolicyTargetMixin, tables.LinkAction):
name = "backups"
verbose_name = _("Create Backup")
url = "horizon:project:volumes:create_snapshot_backup"
classes = ("ajax-modal",)
policy_rules = (("volume", "backup:create"),)
def get_link_url(self, datum):
snap_id = self.table.get_object_id(datum)
url = reverse(self.url, args=(datum.volume_id, snap_id))
return url
def allowed(self, request, snapshot=None):
return (cinder.volume_backup_supported(request) and
snapshot.status == 'available')
class UpdateMetadata(tables.LinkAction):
name = "update_metadata"
verbose_name = _("Update Metadata")
@ -230,7 +249,7 @@ class VolumeDetailsSnapshotsTable(volume_tables.VolumesTableBase):
launch_actions = (LaunchSnapshotNG,) + launch_actions
row_actions = ((CreateVolumeFromSnapshot,) + launch_actions +
(EditVolumeSnapshot, DeleteVolumeSnapshot,
(EditVolumeSnapshot, DeleteVolumeSnapshot, CreateBackup,
UpdateMetadata))
row_class = UpdateRow
status_columns = ("status",)

View File

@ -42,6 +42,9 @@ urlpatterns = [
url(r'^(?P<volume_id>[^/]+)/create_backup/$',
backup_views.CreateBackupView.as_view(),
name='create_backup'),
url(r'^(?P<volume_id>[^/]+)/create_backup/(?P<snapshot_id>[^/]+)$',
backup_views.CreateBackupView.as_view(),
name='create_snapshot_backup'),
url(r'^(?P<volume_id>[^/]+)/$',
views.DetailView.as_view(),
name='detail'),

View File

@ -204,6 +204,7 @@ def data(TEST):
'display_name': 'test snapshot',
'display_description': 'volume snapshot',
'size': 40,
'created_at': '2014-01-27 10:30:00',
'status': 'available',
'volume_id': '11023e92-8008-4c8b-8059-7f2293ff3887'})
snapshot2 = vol_snaps.Snapshot(
@ -212,6 +213,7 @@ def data(TEST):
'name': '',
'description': 'v2 volume snapshot description',
'size': 80,
'created_at': '2014-01-27 10:30:00',
'status': 'available',
'volume_id': '31023e92-8008-4c8b-8059-7f2293ff1234'})
snapshot3 = vol_snaps.Snapshot(
@ -220,6 +222,7 @@ def data(TEST):
'name': '',
'description': 'v2 volume snapshot description 2',
'size': 80,
'created_at': '2014-01-27 10:30:00',
'status': 'available',
'volume_id': '31023e92-8008-4c8b-8059-7f2293ff1234'})
snapshot4 = vol_snaps.Snapshot(
@ -228,6 +231,7 @@ def data(TEST):
'name': '',
'description': 'v2 volume snapshot with metadata description',
'size': 80,
'created_at': '2014-01-27 10:30:00',
'status': 'available',
'volume_id': '31023e92-8008-4c8b-8059-7f2293ff1234',
'metadata': {'snapshot_meta_key': 'snapshot_meta_value'}})
@ -269,6 +273,7 @@ def data(TEST):
'size': 10,
'status': 'available',
'container_name': 'volumebackups',
'snapshot_id': None,
'volume_id': '11023e92-8008-4c8b-8059-7f2293ff3887'})
volume_backup2 = vol_backups.VolumeBackup(
@ -278,6 +283,7 @@ def data(TEST):
'description': 'volume backup 2',
'size': 20,
'status': 'available',
'snapshot_id': snapshot2.id,
'container_name': 'volumebackups',
'volume_id': '31023e92-8008-4c8b-8059-7f2293ff1234'})
@ -287,6 +293,7 @@ def data(TEST):
'name': 'backup3',
'description': 'volume backup 3',
'size': 20,
'snapshot_id': None,
'status': 'available',
'container_name': 'volumebackups',
'volume_id': '31023e92-8008-4c8b-8059-7f2293ff1234'})

View File

@ -0,0 +1,5 @@
---
features:
- Enabled horizon to make use of cinder's feature to
backup up snapshots of block storage volumes.