diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst index 1f853a86d7..ad9b508f1e 100644 --- a/doc/source/topics/settings.rst +++ b/doc/source/topics/settings.rst @@ -423,6 +423,17 @@ are using HTTPS, running your Keystone server on a nonstandard port, or using a nonstandard URL scheme you shouldn't need to touch this setting. +``OPENSTACK_CINDER_FEATURES`` +----------------------------- + +.. versionadded:: 2014.2(Juno) + +Default: ``{'enable_backup': False}`` + +A dictionary of settings which can be used to enable optional services provided +by cinder. Currently only the backup service is available. + + ``OPENSTACK_NEUTRON_NETWORK`` ----------------------------- diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 089f4ddc48..bb54045d83 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -93,6 +93,21 @@ class VolumeSnapshot(BaseCinderAPIResourceWrapper): 'os-extended-snapshot-attributes:project_id'] +class VolumeBackup(BaseCinderAPIResourceWrapper): + + _attrs = ['id', 'name', 'description', 'container', 'size', 'status', + 'created_at', 'volume_id', 'availability_zone'] + _volume = None + + @property + def volume(self): + return self._volume + + @volume.setter + def volume(self, value): + self._volume = value + + def cinderclient(request): api_version = VERSIONS.get_active_version() @@ -232,6 +247,52 @@ def volume_snapshot_update(request, snapshot_id, name, description): **snapshot_data) +@memoized +def volume_backup_supported(request): + """This method will determine if cinder supports backup. + """ + # TODO(lcheng) Cinder does not expose the information if cinder + # backup is configured yet. This is a workaround until that + # capability is available. + # https://bugs.launchpad.net/cinder/+bug/1334856 + cinder_config = getattr(settings, 'OPENSTACK_CINDER_FEATURES', {}) + return cinder_config.get('enable_backup', False) + + +def volume_backup_get(request, backup_id): + backup = cinderclient(request).backups.get(backup_id) + return VolumeBackup(backup) + + +def volume_backup_list(request): + c_client = cinderclient(request) + if c_client is None: + return [] + return [VolumeBackup(b) for b in c_client.backups.list()] + + +def volume_backup_create(request, + volume_id, + container_name, + name, + description): + backup = cinderclient(request).backups.create( + volume_id, + container=container_name, + name=name, + description=description) + return VolumeBackup(backup) + + +def volume_backup_delete(request, backup_id): + return cinderclient(request).backups.delete(backup_id) + + +def volume_backup_restore(request, backup_id, volume_id): + return cinderclient(request).restores.restore(backup_id=backup_id, + volume_id=volume_id) + + def tenant_quota_get(request, tenant_id): c_client = cinderclient(request) if c_client is None: diff --git a/openstack_dashboard/conf/cinder_policy.json b/openstack_dashboard/conf/cinder_policy.json index eb32440619..7d1ef6317c 100644 --- a/openstack_dashboard/conf/cinder_policy.json +++ b/openstack_dashboard/conf/cinder_policy.json @@ -49,11 +49,11 @@ "volume:delete_transfer": [], "volume:get_all_transfers": [], - "backup:create" : [], - "backup:delete": [], + "backup:create" : ["rule:default"], + "backup:delete": ["rule:default"], "backup:get": [], "backup:get_all": [], - "backup:restore": [], + "backup:restore": ["rule:default"], "snapshot_extension:snapshot_actions:update_snapshot_status": [] } diff --git a/openstack_dashboard/dashboards/project/volumes/backups/__init__.py b/openstack_dashboard/dashboards/project/volumes/backups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/volumes/backups/forms.py b/openstack_dashboard/dashboards/project/volumes/backups/forms.py new file mode 100644 index 0000000000..885fbaa420 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/forms.py @@ -0,0 +1,109 @@ +# 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. + + +""" +Views for managing backups. +""" + +import operator + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.containers.forms \ + import no_slash_validator + + +class CreateBackupForm(forms.SelfHandlingForm): + name = forms.CharField(max_length="255", label=_("Backup Name")) + description = forms.CharField(widget=forms.Textarea, + label=_("Description"), + required=False) + container_name = forms.CharField(max_length="255", + label=_("Container Name"), + validators=[no_slash_validator], + required=False) + volume_id = forms.CharField(widget=forms.HiddenInput()) + + def handle(self, request, data): + # Create a container for the user if no input is given + if not data['container_name']: + data['container_name'] = 'volumebackups' + + try: + backup = api.cinder.volume_backup_create(request, + data['volume_id'], + data['container_name'], + data['name'], + data['description']) + + message = _('Creating volume backup "%s"') % data['name'] + messages.success(request, message) + return backup + + except Exception: + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(request, + _('Unable to create volume backup.'), + redirect=redirect) + return False + + +class RestoreBackupForm(forms.SelfHandlingForm): + volume_id = forms.ChoiceField(label=_('Select Volume'), required=False) + backup_id = forms.CharField(widget=forms.HiddenInput()) + backup_name = forms.CharField(widget=forms.HiddenInput()) + + def __init__(self, request, *args, **kwargs): + super(RestoreBackupForm, self).__init__(request, *args, **kwargs) + + try: + volumes = api.cinder.volume_list(request) + except Exception: + msg = _('Unable to lookup volume or backup information.') + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(request, msg, redirect=redirect) + raise exceptions.Http302(redirect) + + volumes.sort(key=operator.attrgetter('name', 'created_at')) + choices = [('', _('Create a New Volume'))] + choices.extend((volume.id, volume.name) for volume in volumes) + self.fields['volume_id'].choices = choices + + def handle(self, request, data): + backup_id = data['backup_id'] + backup_name = data['backup_name'] or None + volume_id = data['volume_id'] or None + + try: + restore = api.cinder.volume_backup_restore(request, + backup_id, + volume_id) + + # Needed for cases when a new volume is created. + volume_id = restore.volume_id + + message = _('Successfully restored backup %(backup_name)s ' + 'to volume with id: %(volume_id)s') + messages.success(request, message % {'backup_name': backup_name, + 'volume_id': volume_id}) + return restore + except Exception: + msg = _('Unable to restore backup.') + exceptions.handle(request, msg) + return False diff --git a/openstack_dashboard/dashboards/project/volumes/backups/tables.py b/openstack_dashboard/dashboards/project/volumes/backups/tables.py new file mode 100644 index 0000000000..c0c2455881 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/tables.py @@ -0,0 +1,130 @@ +# 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 django.core.urlresolvers import reverse +from django.template.defaultfilters import title # noqa +from django.utils import html +from django.utils import http +from django.utils import safestring +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables + +from openstack_dashboard import api +from openstack_dashboard.api import cinder + + +DELETABLE_STATES = ("available", "error",) + + +class BackupVolumeNameColumn(tables.Column): + def get_raw_data(self, backup): + volume = backup.volume + if volume: + volume_name = volume.name + volume_name = html.escape(volume_name) + else: + volume_name = _("Unknown") + return safestring.mark_safe(volume_name) + + def get_link_url(self, backup): + volume = backup.volume + if volume: + volume_id = volume.id + return reverse(self.link, args=(volume_id,)) + + +class DeleteBackup(tables.DeleteAction): + data_type_singular = _("Volume Backup") + data_type_plural = _("Volume Backups") + action_past = _("Scheduled deletion of") + policy_rules = (("volume", "backup:delete"),) + + def delete(self, request, obj_id): + api.cinder.volume_backup_delete(request, obj_id) + + def allowed(self, request, volume=None): + if volume: + return volume.status in DELETABLE_STATES + return True + + +class RestoreBackup(tables.LinkAction): + name = "restore" + verbose_name = _("Restore Backup") + classes = ("ajax-modal",) + policy_rules = (("volume", "backup:restore"),) + + def allowed(self, request, volume=None): + return volume.status == "available" + + def get_link_url(self, datum): + backup_id = datum.id + backup_name = datum.name + volume_id = getattr(datum, 'volume_id', None) + url = reverse("horizon:project:volumes:backups:restore", + args=(backup_id,)) + url += '?%s' % http.urlencode({'backup_name': backup_name, + 'volume_id': volume_id}) + return url + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, backup_id): + backup = cinder.volume_backup_get(request, backup_id) + try: + backup.volume = cinder.volume_get(request, + backup.volume_id) + except Exception: + pass + return backup + + +def get_size(backup): + return _("%sGB") % backup.size + + +class BackupsTable(tables.DataTable): + STATUS_CHOICES = ( + ("available", True), + ("creating", None), + ("restoring", None), + ("error", False), + ) + name = tables.Column("name", + verbose_name=_("Name"), + link="horizon:project:volumes:backups:detail") + description = tables.Column("description", + verbose_name=_("Description"), + truncate=40) + size = tables.Column(get_size, + verbose_name=_("Size"), + attrs={'data-type': 'size'}) + status = tables.Column("status", + filters=(title,), + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES) + volume_name = BackupVolumeNameColumn("name", + verbose_name=_("Volume Name"), + link="horizon:project" + ":volumes:volumes:detail") + + class Meta: + name = "volume_backups" + verbose_name = _("Volume Backups") + status_columns = ("status",) + row_class = UpdateRow + table_actions = (DeleteBackup,) + row_actions = (RestoreBackup, DeleteBackup) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/tabs.py b/openstack_dashboard/dashboards/project/volumes/backups/tabs.py new file mode 100644 index 0000000000..87bcd4c446 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/tabs.py @@ -0,0 +1,44 @@ +# 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 django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import tabs + +from openstack_dashboard.api import cinder + + +class BackupOverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = ("project/volumes/backups/" + "_detail_overview.html") + + def get_context_data(self, request): + try: + backup = self.tab_group.kwargs['backup'] + volume = cinder.volume_get(request, backup.volume_id) + return {'backup': backup, + 'volume': volume} + except Exception: + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(self.request, + _('Unable to retrieve backup details.'), + redirect=redirect) + + +class BackupDetailTabs(tabs.TabGroup): + slug = "backup_details" + tabs = (BackupOverviewTab,) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/tests.py b/openstack_dashboard/dashboards/project/volumes/backups/tests.py new file mode 100644 index 0000000000..8b22b140d0 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/tests.py @@ -0,0 +1,186 @@ +# 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 django.core.urlresolvers import reverse +from django import http +from django.utils.http import urlencode +from mox import IsA # noqa + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test +from openstack_dashboard.usage import quotas + + +INDEX_URL = reverse('horizon:project:volumes:index') +VOLUME_BACKUPS_TAB_URL = reverse('horizon:project:volumes:backups_tab') + + +class VolumeBackupsViewTests(test.TestCase): + + @test.create_stubs({api.cinder: ('volume_backup_create',)}) + def test_create_backup_post(self): + volume = self.volumes.first() + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_create(IsA(http.HttpRequest), + volume.id, + backup.container_name, + backup.name, + backup.description) \ + .AndReturn(backup) + self.mox.ReplayAll() + + formData = {'method': 'CreateBackupForm', + 'tenant_id': self.tenant.id, + 'volume_id': volume.id, + 'container_name': backup.container_name, + 'name': backup.name, + 'description': backup.description} + url = reverse('horizon:project:volumes:volumes:create_backup', + args=[volume.id]) + res = self.client.post(url, formData) + + self.assertNoFormErrors(res) + self.assertMessageCount(error=0, warning=0) + self.assertRedirectsNoFollow(res, VOLUME_BACKUPS_TAB_URL) + + @test.create_stubs({api.nova: ('server_list',), + api.cinder: ('volume_snapshot_list', + 'volume_list', + 'volume_backup_supported', + 'volume_backup_list', + 'volume_backup_delete'), + quotas: ('tenant_quota_usages',)}) + def test_delete_volume_backup(self): + vol_backups = self.cinder_volume_backups.list() + volumes = self.cinder_volumes.list() + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) + api.cinder.volume_backup_list(IsA(http.HttpRequest)). \ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + api.cinder.volume_backup_delete(IsA(http.HttpRequest), backup.id) + + api.cinder.volume_list(IsA(http.HttpRequest), search_opts=None). \ + AndReturn(volumes) + api.nova.server_list(IsA(http.HttpRequest), search_opts=None). \ + AndReturn([self.servers.list(), False]) + api.cinder.volume_snapshot_list(IsA(http.HttpRequest)). \ + AndReturn([]) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + api.cinder.volume_backup_list(IsA(http.HttpRequest)). \ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes(). \ + AndReturn(self.quota_usages.first()) + self.mox.ReplayAll() + + formData = {'action': + 'volume_backups__delete__%s' % backup.id} + res = self.client.post(INDEX_URL + + "?tab=volumes_and_snapshots__backups_tab", + formData, follow=True) + + self.assertIn("Scheduled deletion of Volume Backup: backup1", + [m.message for m in res.context['messages']]) + + @test.create_stubs({api.cinder: ('volume_backup_get', 'volume_get')}) + def test_volume_backup_detail_get(self): + backup = self.cinder_volume_backups.first() + volume = self.cinder_volumes.get(id=backup.volume_id) + + api.cinder.volume_backup_get(IsA(http.HttpRequest), backup.id). \ + AndReturn(backup) + api.cinder.volume_get(IsA(http.HttpRequest), backup.volume_id). \ + AndReturn(volume) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:backups:detail', + args=[backup.id]) + res = self.client.get(url) + + self.assertContains(res, + "

Volume Backup Details: %s

" % + backup.name, + 1, 200) + self.assertContains(res, "
%s
" % backup.name, 1, 200) + self.assertContains(res, "
%s
" % backup.id, 1, 200) + self.assertContains(res, "
Available
", 1, 200) + + @test.create_stubs({api.cinder: ('volume_backup_get',)}) + def test_volume_backup_detail_get_with_exception(self): + # Test to verify redirect if get volume backup fails + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_get(IsA(http.HttpRequest), backup.id).\ + AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:backups:detail', + args=[backup.id]) + res = self.client.get(url) + + self.assertNoFormErrors(res) + self.assertMessageCount(error=1) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.cinder: ('volume_backup_get', 'volume_get')}) + def test_volume_backup_detail_with_volume_get_exception(self): + # Test to verify redirect if get volume fails + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_get(IsA(http.HttpRequest), backup.id). \ + AndReturn(backup) + api.cinder.volume_get(IsA(http.HttpRequest), backup.volume_id). \ + AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:backups:detail', + args=[backup.id]) + res = self.client.get(url) + + self.assertNoFormErrors(res) + self.assertMessageCount(error=1) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.cinder: ('volume_list', + 'volume_backup_restore',)}) + def test_restore_backup(self): + backup = self.cinder_volume_backups.first() + volumes = self.cinder_volumes.list() + + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + api.cinder.volume_backup_restore(IsA(http.HttpRequest), + backup.id, + backup.volume_id). \ + AndReturn(backup) + self.mox.ReplayAll() + + formData = {'method': 'RestoreBackupForm', + 'backup_id': backup.id, + 'backup_name': backup.name, + 'volume_id': backup.volume_id} + url = reverse('horizon:project:volumes:backups:restore', + args=[backup.id]) + url += '?%s' % urlencode({'backup_name': backup.name, + 'volume_id': backup.volume_id}) + res = self.client.post(url, formData) + + self.assertNoFormErrors(res) + self.assertMessageCount(success=1) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/urls.py b/openstack_dashboard/dashboards/project/volumes/backups/urls.py new file mode 100644 index 0000000000..9d3d9ab925 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/urls.py @@ -0,0 +1,30 @@ +# 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 django.conf.urls import patterns # noqa +from django.conf.urls import url # noqa + +from openstack_dashboard.dashboards.project.volumes.backups import views + + +VIEWS_MOD = ('openstack_dashboard.dashboards.project' + '.volumes.backups.views') + + +urlpatterns = patterns(VIEWS_MOD, + url(r'^(?P[^/]+)/$', + views.BackupDetailView.as_view(), + name='detail'), + url(r'^(?P[^/]+)/restore/$', + views.RestoreBackupView.as_view(), + name='restore'), +) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/views.py b/openstack_dashboard/dashboards/project/volumes/backups/views.py new file mode 100644 index 0000000000..a04fee8507 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/views.py @@ -0,0 +1,88 @@ +# 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 django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tabs +from horizon.utils import memoized + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.volumes.backups \ + import forms as backup_forms +from openstack_dashboard.dashboards.project.volumes.backups \ + import tabs as backup_tabs + + +class CreateBackupView(forms.ModalFormView): + form_class = backup_forms.CreateBackupForm + template_name = 'project/volumes/backups/create_backup.html' + success_url = reverse_lazy("horizon:project:volumes:backups_tab") + + def get_context_data(self, **kwargs): + context = super(CreateBackupView, self).get_context_data(**kwargs) + context['volume_id'] = self.kwargs['volume_id'] + return context + + def get_initial(self): + return {"volume_id": self.kwargs["volume_id"]} + + +class BackupDetailView(tabs.TabView): + tab_group_class = backup_tabs.BackupDetailTabs + template_name = 'project/volumes/backups/detail.html' + + def get_context_data(self, **kwargs): + context = super(BackupDetailView, self).get_context_data(**kwargs) + context["backup"] = self.get_data() + return context + + @memoized.memoized_method + def get_data(self): + try: + backup_id = self.kwargs['backup_id'] + backup = api.cinder.volume_backup_get(self.request, + backup_id) + except Exception: + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(self.request, + _('Unable to retrieve backup details.'), + redirect=redirect) + return backup + + def get_tabs(self, request, *args, **kwargs): + backup = self.get_data() + return self.tab_group_class(request, backup=backup, **kwargs) + + +class RestoreBackupView(forms.ModalFormView): + form_class = backup_forms.RestoreBackupForm + template_name = 'project/volumes/backups/restore_backup.html' + success_url = reverse_lazy('horizon:project:volumes:index') + + def get_context_data(self, **kwargs): + context = super(RestoreBackupView, self).get_context_data(**kwargs) + context['backup_id'] = self.kwargs['backup_id'] + return context + + def get_initial(self): + backup_id = self.kwargs['backup_id'] + backup_name = self.request.GET.get('backup_name') + volume_id = self.request.GET.get('volume_id') + return { + 'backup_id': backup_id, + 'backup_name': backup_name, + 'volume_id': volume_id, + } diff --git a/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py b/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py index 426b7ba9f9..858d8fea11 100644 --- a/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py @@ -107,13 +107,18 @@ class VolumeSnapshotsViewTests(test.TestCase): @test.create_stubs({api.nova: ('server_list',), api.cinder: ('volume_snapshot_list', 'volume_list', + 'volume_backup_supported', + 'volume_backup_list', 'volume_snapshot_delete'), quotas: ('tenant_quota_usages',)}) def test_delete_volume_snapshot(self): vol_snapshots = self.cinder_volume_snapshots.list() volumes = self.cinder_volumes.list() + vol_backups = self.cinder_volume_backups.list() snapshot = self.cinder_volume_snapshots.first() + api.cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) api.cinder.volume_snapshot_list(IsA(http.HttpRequest)). \ AndReturn(vol_snapshots) api.cinder.volume_list(IsA(http.HttpRequest)). \ @@ -128,6 +133,10 @@ class VolumeSnapshotsViewTests(test.TestCase): AndReturn([]) api.cinder.volume_list(IsA(http.HttpRequest)). \ AndReturn(volumes) + api.cinder.volume_backup_list(IsA(http.HttpRequest)). \ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes(). \ AndReturn(self.quota_usages.first()) self.mox.ReplayAll() diff --git a/openstack_dashboard/dashboards/project/volumes/tabs.py b/openstack_dashboard/dashboards/project/volumes/tabs.py index 3b9e426a36..e067c9f514 100644 --- a/openstack_dashboard/dashboards/project/volumes/tabs.py +++ b/openstack_dashboard/dashboards/project/volumes/tabs.py @@ -20,6 +20,8 @@ from horizon import tabs from openstack_dashboard import api +from openstack_dashboard.dashboards.project.volumes.backups \ + import tables as backups_tables from openstack_dashboard.dashboards.project.volumes.snapshots \ import tables as vol_snapshot_tables from openstack_dashboard.dashboards.project.volumes.volumes \ @@ -95,7 +97,30 @@ class SnapshotTab(tabs.TableTab): return snapshots +class BackupsTab(tabs.TableTab, VolumeTableMixIn): + table_classes = (backups_tables.BackupsTable,) + name = _("Volume Backups") + slug = "backups_tab" + template_name = ("horizon/common/_detail_table.html") + + def allowed(self, request): + return api.cinder.volume_backup_supported(self.request) + + def get_volume_backups_data(self): + try: + backups = api.cinder.volume_backup_list(self.request) + volumes = api.cinder.volume_list(self.request) + volumes = dict((v.id, v) for v in volumes) + for backup in backups: + backup.volume = volumes.get(backup.volume_id) + except Exception: + backups = [] + exceptions.handle(self.request, _("Unable to retrieve " + "volume backups.")) + return backups + + class VolumeAndSnapshotTabs(tabs.TabGroup): slug = "volumes_and_snapshots" - tabs = (VolumeTab, SnapshotTab,) + tabs = (VolumeTab, SnapshotTab, BackupsTab) sticky = True diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_create_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_create_backup.html new file mode 100644 index 0000000000..21c0d34ef1 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_create_backup.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:project:volumes:volumes:create_backup' volume_id %}{% endblock %} + +{% block modal_id %}create_volume_backup_modal{% endblock %} +{% block modal-header %}{% trans "Create Volume Backup" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Volume Backup" %}: {% trans "Volume Backups are stored using the Object Storage service. You must have this service activated in order to create a backup." %}

+

{% trans "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." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_detail_overview.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_detail_overview.html new file mode 100644 index 0000000000..8bb8597637 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_detail_overview.html @@ -0,0 +1,51 @@ +{% load i18n sizeformat parse_date %} +{% load url from future %} + +

{% trans "Volume Backup Overview" %}: {{backup.display_name }}

+ +
+

{% trans "Info" %}

+
+
+
{% trans "Name" %}
+
{{ backup.name }}
+
{% trans "ID" %}
+
{{ backup.id }}
+ {% if backup.description %} +
{% trans "Description" %}
+
{{ backup.description }}
+ {% endif %} +
{% trans "Status" %}
+
{{ backup.status|capfirst }}
+
{% trans "Volume" %}
+
+ + {{ volume.name }} + +
+ +
+
+ +
+

{% trans "Specs" %}

+
+
+
{% trans "Size" %}
+
{{ backup.size }} {% trans "GB" %}
+
{% trans "Created" %}
+
{{ backup.created_at|parse_date }}
+
+
+ + +
+

{% trans "Metadata" %}

+
+
+ {% for key, value in backup.metadata.items %} +
{{ key }}
+
{{ value }}
+ {% endfor %} +
+
diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_restore_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_restore_backup.html new file mode 100644 index 0000000000..d0df6278c5 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_restore_backup.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:project:volumes:backups:restore' backup_id %}{% endblock %} + +{% block modal_id %}restore_volume_backup_modal{% endblock %} +{% block modal-header %}{% trans "Restore Volume Backup" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Restore Backup" %}: {% trans "Select a volume to restore to." %}

+

{% trans "Optionally, you may choose to create a new volume." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/create_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/create_backup.html new file mode 100644 index 0000000000..abdc3bc4ef --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/create_backup.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Volume Backup" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create a Volume Backup") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/backups/_create_backup.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/detail.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/detail.html new file mode 100644 index 0000000000..c195f01206 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/detail.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Volume Backup Details" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Volume Backup Details: ")|add:backup.name|default:_("Volume Backup Details:") %} +{% endblock page_header %} +{% block main %} +
+
+ {{ tab_group.render }} +
+
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/restore_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/restore_backup.html new file mode 100644 index 0000000000..ff053ea556 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/restore_backup.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Restore Volume Backup" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Restore a Volume Backup") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/backups/_restore_backup.html' %} +{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html index 5df0fcaae4..57333ed5e5 100644 --- a/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html @@ -1,9 +1,9 @@ {% extends 'base.html' %} {% load i18n %} -{% block title %}{% trans "Volumes & Snapshots" %}{% endblock %} +{% block title %}{% trans "Volumes" %}{% endblock %} {% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Volumes & Snapshots")%} + {% include "horizon/common/_page_header.html" with title=_("Volumes")%} {% endblock page_header %} {% block main %} diff --git a/openstack_dashboard/dashboards/project/volumes/test.py b/openstack_dashboard/dashboards/project/volumes/test.py index 5a1694447e..405461c793 100644 --- a/openstack_dashboard/dashboards/project/volumes/test.py +++ b/openstack_dashboard/dashboards/project/volumes/test.py @@ -27,13 +27,19 @@ INDEX_URL = reverse('horizon:project:volumes:index') class VolumeAndSnapshotsTests(test.TestCase): @test.create_stubs({api.cinder: ('volume_list', - 'volume_snapshot_list',), + 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list', + ), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) - def test_index(self): + def _test_index(self, backup_supported=True): + vol_backups = self.cinder_volume_backups.list() vol_snaps = self.cinder_volume_snapshots.list() volumes = self.cinder_volumes.list() + api.cinder.volume_backup_supported(IsA(http.HttpRequest)).\ + MultipleTimes().AndReturn(backup_supported) api.cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) api.nova.server_list(IsA(http.HttpRequest), search_opts=None).\ @@ -41,6 +47,10 @@ class VolumeAndSnapshotsTests(test.TestCase): api.cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(vol_snaps) api.cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) + if backup_supported: + api.cinder.volume_backup_list(IsA(http.HttpRequest)).\ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes(). \ AndReturn(self.quota_usages.first()) self.mox.ReplayAll() @@ -48,3 +58,9 @@ class VolumeAndSnapshotsTests(test.TestCase): res = self.client.get(INDEX_URL) self.assertEqual(res.status_code, 200) self.assertTemplateUsed(res, 'project/volumes/index.html') + + def test_index_back_supported(self): + self._test_index(backup_supported=True) + + def test_index_backup_not_supported(self): + self._test_index(backup_supported=False) diff --git a/openstack_dashboard/dashboards/project/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/urls.py index 4831f5fc09..c6e66a724f 100644 --- a/openstack_dashboard/dashboards/project/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/urls.py @@ -16,19 +16,23 @@ from django.conf.urls import include # noqa from django.conf.urls import patterns # noqa from django.conf.urls import url # noqa +from openstack_dashboard.dashboards.project.volumes.backups \ + import urls as backups_urls from openstack_dashboard.dashboards.project.volumes.snapshots \ import urls as snapshot_urls from openstack_dashboard.dashboards.project.volumes import views from openstack_dashboard.dashboards.project.volumes.volumes \ import urls as volume_urls - urlpatterns = patterns('', url(r'^$', views.IndexView.as_view(), name='index'), url(r'^\?tab=volumes_and_snapshots__snapshots_tab$', views.IndexView.as_view(), name='snapshots_tab'), url(r'^\?tab=volumes_and_snapshots__volumes_tab$', views.IndexView.as_view(), name='volumes_tab'), + url(r'^\?tab=volumes_and_snapshots__backups_tab$', + views.IndexView.as_view(), name='backups_tab'), url(r'', include(volume_urls, namespace='volumes')), + url(r'backups/', include(backups_urls, namespace='backups')), url(r'snapshots/', include(snapshot_urls, namespace='snapshots')), ) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index 7568296cea..eaacbe8b0d 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -162,6 +162,24 @@ class CreateSnapshot(tables.LinkAction): return volume.status in ("available", "in-use") +class CreateBackup(tables.LinkAction): + name = "backups" + verbose_name = _("Create Backup") + url = "horizon:project:volumes:volumes:create_backup" + classes = ("ajax-modal",) + policy_rules = (("volume", "backup:create"),) + + def get_policy_target(self, request, datum=None): + project_id = None + if datum: + project_id = getattr(datum, "os-vol-tenant-attr:tenant_id", None) + return {"project_id": project_id} + + def allowed(self, request, volume=None): + return (cinder.volume_backup_supported(request) and + volume.status == "available") + + class EditVolume(tables.LinkAction): name = "edit" verbose_name = _("Edit Volume") @@ -298,7 +316,7 @@ class VolumesTable(VolumesTableBase): row_class = UpdateRow table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction) row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments, - CreateSnapshot, DeleteVolume) + CreateSnapshot, CreateBackup, DeleteVolume) class DetachVolume(tables.BatchAction): diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py index cdee821273..f893a98c5e 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py @@ -706,6 +706,8 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_list', 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list', 'volume_delete',), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) @@ -715,6 +717,8 @@ class VolumeViewTests(test.TestCase): formData = {'action': 'volumes__delete__%s' % volume.id} + cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) cinder.volume_delete(IsA(http.HttpRequest), volume.id) @@ -724,6 +728,10 @@ class VolumeViewTests(test.TestCase): AndReturn(self.cinder_volume_snapshots.list()) cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) + cinder.volume_backup_list(IsA(http.HttpRequest)).\ + AndReturn(self.cinder_volume_backups.list()) + cinder.volume_list(IsA(http.HttpRequest)).\ + AndReturn(volumes) api.nova.server_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn([self.servers.list(), False]) cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) @@ -739,6 +747,8 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_list', 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list', 'volume_delete',), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) @@ -750,6 +760,8 @@ class VolumeViewTests(test.TestCase): exc = self.exceptions.cinder.__class__(400, "error: dependent snapshots") + cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) cinder.volume_delete(IsA(http.HttpRequest), volume.id).\ @@ -763,6 +775,10 @@ class VolumeViewTests(test.TestCase): cinder.volume_snapshot_list(IsA(http.HttpRequest))\ .AndReturn(self.cinder_volume_snapshots.list()) cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) + cinder.volume_backup_list(IsA(http.HttpRequest)).\ + AndReturn(self.cinder_volume_backups.list()) + cinder.volume_list(IsA(http.HttpRequest)).\ + AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes().\ AndReturn(self.quota_usages.first()) @@ -857,7 +873,9 @@ class VolumeViewTests(test.TestCase): self.assertEqual(res.status_code, 200) @test.create_stubs({cinder: ('volume_list', - 'volume_snapshot_list'), + 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list',), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) def test_create_button_disabled_when_quota_exceeded(self): @@ -865,6 +883,8 @@ class VolumeViewTests(test.TestCase): quota_usages['volumes']['available'] = 0 volumes = self.cinder_volumes.list() + api.cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) cinder.volume_list(IsA(http.HttpRequest), search_opts=None)\ .AndReturn(volumes) api.nova.server_list(IsA(http.HttpRequest), search_opts=None)\ @@ -872,6 +892,9 @@ class VolumeViewTests(test.TestCase): cinder.volume_snapshot_list(IsA(http.HttpRequest))\ .AndReturn(self.cinder_volume_snapshots.list()) cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) + cinder.volume_backup_list(IsA(http.HttpRequest))\ + .AndReturn(self.cinder_volume_backups.list()) + cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest))\ .MultipleTimes().AndReturn(quota_usages) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py index ced497b13b..a2d8ce314c 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py @@ -17,6 +17,8 @@ from django.conf.urls import url # noqa from openstack_dashboard.dashboards.project.volumes \ .volumes import views +from openstack_dashboard.dashboards.project.volumes.backups \ + import views as backup_views VIEWS_MOD = ('openstack_dashboard.dashboards.project.volumes.volumes.views') @@ -32,6 +34,9 @@ urlpatterns = patterns(VIEWS_MOD, url(r'^(?P[^/]+)/create_snapshot/$', views.CreateSnapshotView.as_view(), name='create_snapshot'), + url(r'^(?P[^/]+)/create_backup/$', + backup_views.CreateBackupView.as_view(), + name='create_backup'), url(r'^(?P[^/]+)/$', views.DetailView.as_view(), name='detail'), diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index 3a3a20e3ad..25a8ff082f 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -168,6 +168,12 @@ OPENSTACK_HYPERVISOR_FEATURES = { 'can_set_password': False, } +# The OPENSTACK_CINDER_FEATURES settings can be used to enable optional +# services provided by cinder that is not exposed by its extension API. +OPENSTACK_CINDER_FEATURES = { + 'enable_backup': False, +} + # The OPENSTACK_NEUTRON_NETWORK settings can be used to enable optional # services provided by neutron. Options currently available are load # balancer service, security groups, quotas, VPN service. diff --git a/openstack_dashboard/test/settings.py b/openstack_dashboard/test/settings.py index 5e9b01b002..8c2cd00783 100644 --- a/openstack_dashboard/test/settings.py +++ b/openstack_dashboard/test/settings.py @@ -112,6 +112,10 @@ OPENSTACK_KEYSTONE_BACKEND = { 'can_edit_role': True } +OPENSTACK_CINDER_FEATURES = { + 'enable_backup': True, +} + OPENSTACK_NEUTRON_NETWORK = { 'enable_lb': True, 'enable_firewall': True, diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index 4c9233f86a..bfa1f609e9 100644 --- a/openstack_dashboard/test/test_data/cinder_data.py +++ b/openstack_dashboard/test/test_data/cinder_data.py @@ -17,6 +17,7 @@ from cinderclient.v1 import quotas from cinderclient.v1 import services from cinderclient.v1 import volume_snapshots as vol_snaps from cinderclient.v1 import volumes +from cinderclient.v2 import volume_backups as vol_backups from cinderclient.v2 import volume_snapshots as vol_snaps_v2 from cinderclient.v2 import volumes as volumes_v2 @@ -29,12 +30,13 @@ from openstack_dashboard.test.test_data import utils def data(TEST): TEST.cinder_services = utils.TestDataContainer() TEST.cinder_volumes = utils.TestDataContainer() + TEST.cinder_volume_backups = utils.TestDataContainer() TEST.cinder_volume_snapshots = utils.TestDataContainer() TEST.cinder_quotas = utils.TestDataContainer() TEST.cinder_quota_usages = utils.TestDataContainer() TEST.cinder_availability_zones = utils.TestDataContainer() - # Services + # Services service_1 = services.Service(services.ServiceManager(None), { "service": "cinder-scheduler", @@ -138,6 +140,29 @@ def data(TEST): TEST.cinder_volume_snapshots.add(api.cinder.VolumeSnapshot(snapshot)) TEST.cinder_volume_snapshots.add(api.cinder.VolumeSnapshot(snapshot2)) + volume_backup1 = vol_backups.VolumeBackup(vol_backups. + VolumeBackupManager(None), + {'id': 'a374cbb8-3f99-4c3f-a2ef-3edbec842e31', + 'name': 'backup1', + 'description': 'volume backup 1', + 'size': 10, + 'status': 'available', + 'container_name': 'volumebackups', + 'volume_id': '11023e92-8008-4c8b-8059-7f2293ff3887'}) + + volume_backup2 = vol_backups.VolumeBackup(vol_backups. + VolumeBackupManager(None), + {'id': 'c321cbb8-3f99-4c3f-a2ef-3edbec842e52', + 'name': 'backup2', + 'description': 'volume backup 2', + 'size': 20, + 'status': 'available', + 'container_name': 'volumebackups', + 'volume_id': '31023e92-8008-4c8b-8059-7f2293ff1234'}) + + TEST.cinder_volume_backups.add(volume_backup1) + TEST.cinder_volume_backups.add(volume_backup2) + # Quota Sets quota_data = dict(volumes='1', snapshots='1',