Merge "Adding support for volume backups"

This commit is contained in:
Jenkins 2014-07-08 08:17:08 +00:00 committed by Gerrit Code Review
commit 1ddd7f0e92
27 changed files with 945 additions and 12 deletions

View File

@ -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``
-----------------------------

View File

@ -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:

View File

@ -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": []
}

View File

@ -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

View File

@ -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)

View File

@ -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,)

View File

@ -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,
"<h2>Volume Backup Details: %s</h2>" %
backup.name,
1, 200)
self.assertContains(res, "<dd>%s</dd>" % backup.name, 1, 200)
self.assertContains(res, "<dd>%s</dd>" % backup.id, 1, 200)
self.assertContains(res, "<dd>Available</dd>", 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)

View File

@ -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<backup_id>[^/]+)/$',
views.BackupDetailView.as_view(),
name='detail'),
url(r'^(?P<backup_id>[^/]+)/restore/$',
views.RestoreBackupView.as_view(),
name='restore'),
)

View File

@ -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,
}

View File

@ -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()

View File

@ -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

View File

@ -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 %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<p><strong>{% trans "Volume Backup" %}</strong>: {% trans "Volume Backups are stored using the Object Storage service. You must have this service activated in order to create a backup." %}</p>
<p>{% 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." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Create Volume Backup" %}" />
<a href="{% url 'horizon:project:volumes:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% load i18n sizeformat parse_date %}
{% load url from future %}
<h3>{% trans "Volume Backup Overview" %}: {{backup.display_name }}</h3>
<div class="info row-fluid detail">
<h4>{% trans "Info" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Name" %}</dt>
<dd>{{ backup.name }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ backup.id }}</dd>
{% if backup.description %}
<dt>{% trans "Description" %}</dt>
<dd>{{ backup.description }}</dd>
{% endif %}
<dt>{% trans "Status" %}</dt>
<dd>{{ backup.status|capfirst }}</dd>
<dt>{% trans "Volume" %}</dt>
<dd>
<a href="{% url 'horizon:project:volumes:volumes:detail' backup.volume_id %}">
{{ volume.name }}
</a>
</dd>
</dl>
</div>
<div class="specs row-fluid detail">
<h4>{% trans "Specs" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Size" %}</dt>
<dd>{{ backup.size }} {% trans "GB" %}</dd>
<dt>{% trans "Created" %}</dt>
<dd>{{ backup.created_at|parse_date }}</dd>
</dl>
</div>
<div class="status row-fluid detail">
<h4>{% trans "Metadata" %}</h4>
<hr class="header_rule">
<dl>
{% for key, value in backup.metadata.items %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
</div>

View File

@ -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 %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<p><strong>{% trans "Restore Backup" %}</strong>: {% trans "Select a volume to restore to." %}</p>
<p>{% trans "Optionally, you may choose to create a new volume." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Restore Backup to Volume"%}" />
<a href="{% url 'horizon:project:volumes:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}
<div class="row-fluid">
<div class="span12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}

View File

@ -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 %}

View File

@ -1,9 +1,9 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Volumes &amp; Snapshots" %}{% endblock %}
{% block title %}{% trans "Volumes" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Volumes &amp; Snapshots")%}
{% include "horizon/common/_page_header.html" with title=_("Volumes")%}
{% endblock page_header %}
{% block main %}

View File

@ -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)

View File

@ -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')),
)

View File

@ -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):

View File

@ -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)

View File

@ -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<volume_id>[^/]+)/create_snapshot/$',
views.CreateSnapshotView.as_view(),
name='create_snapshot'),
url(r'^(?P<volume_id>[^/]+)/create_backup/$',
backup_views.CreateBackupView.as_view(),
name='create_backup'),
url(r'^(?P<volume_id>[^/]+)/$',
views.DetailView.as_view(),
name='detail'),

View File

@ -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.

View File

@ -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,

View File

@ -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',