Add support for volume consistency group snapshots

This extends the support for Cinder volume consistency groups.
This patch adds snapshot support for all volumes contained
within a consistency group.

Additional features includes the ability to create a new
consistency group from an existing consistency group
or consistency group snapshot. This will involve cloning
all volumes in the original CG.

To enable this feature, update "consistencygroup" items in
the cinder policy file (/etc/cinder/policy.json). The default
is that all consistencygroup actions are disabled.

Co-Authored-By: Brad Pokorny <brad_pokorny@symantec.com>
Change-Id: I2e2ae26369a23415bf58d6edac8d161d3a125dd6
Implements: blueprint cinder-consistency-groups
This commit is contained in:
Rich Hagarty 2015-12-09 09:04:45 -08:00 committed by Travis Tripp
parent 7df4449e2c
commit b1a22463d6
35 changed files with 1541 additions and 165 deletions

View File

@ -109,6 +109,12 @@ class VolumeConsistencyGroup(BaseCinderAPIResourceWrapper):
'created_at', 'volume_types']
class VolumeCGSnapshot(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'status',
'created_at', 'consistencygroup_id']
class VolumeBackup(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'container', 'size', 'status',
@ -432,6 +438,18 @@ def volume_cgroup_get(request, cgroup_id):
return VolumeConsistencyGroup(cgroup)
def volume_cgroup_get_with_vol_type_names(request, cgroup_id):
cgroup = volume_cgroup_get(request, cgroup_id)
vol_types = volume_type_list(request)
cgroup.volume_type_names = []
for vol_type_id in cgroup.volume_types:
for vol_type in vol_types:
if vol_type.id == vol_type_id:
cgroup.volume_type_names.append(vol_type.name)
break
return cgroup
def volume_cgroup_list(request, search_opts=None):
c_client = cinderclient(request)
if c_client is None:
@ -442,23 +460,41 @@ def volume_cgroup_list(request, search_opts=None):
def volume_cgroup_list_with_vol_type_names(request, search_opts=None):
cgroups = volume_cgroup_list(request, search_opts)
vol_types = volume_type_list(request)
for cgroup in cgroups:
cgroup.volume_type_names = []
for vol_type_id in cgroup.volume_types:
vol_type = volume_type_get(request, vol_type_id)
for vol_type in vol_types:
if vol_type.id == vol_type_id:
cgroup.volume_type_names.append(vol_type.name)
break
return cgroups
def volume_cgroup_create(request, volume_types, name,
description=None, availability_zone=None):
data = {'name': name,
'description': description,
'availability_zone': availability_zone}
cgroup = cinderclient(request).consistencygroups.create(volume_types,
**data)
return VolumeConsistencyGroup(cgroup)
def volume_cgroup_create_from_source(request, name, cg_snapshot_id=None,
source_cgroup_id=None,
description=None,
user_id=None, project_id=None):
return VolumeConsistencyGroup(
cinderclient(request).consistencygroups.create(
volume_types,
cinderclient(request).consistencygroups.create_from_src(
cg_snapshot_id,
source_cgroup_id,
name,
description,
availability_zone=availability_zone))
user_id,
project_id))
def volume_cgroup_delete(request, cgroup_id, force=False):
@ -480,6 +516,32 @@ def volume_cgroup_update(request, cgroup_id, name=None, description=None,
**cgroup_data)
def volume_cg_snapshot_create(request, cgroup_id, name,
description=None):
return VolumeCGSnapshot(
cinderclient(request).cgsnapshots.create(
cgroup_id,
name,
description))
def volume_cg_snapshot_get(request, cg_snapshot_id):
cgsnapshot = cinderclient(request).cgsnapshots.get(cg_snapshot_id)
return VolumeCGSnapshot(cgsnapshot)
def volume_cg_snapshot_list(request, search_opts=None):
c_client = cinderclient(request)
if c_client is None:
return []
return [VolumeCGSnapshot(s) for s in c_client.cgsnapshots.list(
search_opts=search_opts)]
def volume_cg_snapshot_delete(request, cg_snapshot_id):
return cinderclient(request).cgsnapshots.delete(cg_snapshot_id)
@memoized
def volume_backup_supported(request):
"""This method will determine if cinder supports backup.

View File

@ -0,0 +1,76 @@
# 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 forms
from horizon import messages
from openstack_dashboard.api import cinder
class CreateCGroupForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Consistency Group Name"))
description = forms.CharField(max_length=255,
widget=forms.Textarea(attrs={'rows': 4}),
label=_("Description"),
required=False)
snapshot_source = forms.ChoiceField(
label=_("Use snapshot as a source"),
widget=forms.SelectWidget(
attrs={'class': 'snapshot-selector'},
data_attrs=('name'),
transform=lambda x: "%s" % (x.name)),
required=False)
def prepare_snapshot_source_field(self, request, cg_snapshot_id):
try:
cg_snapshot = cinder.volume_cg_snapshot_get(request,
cg_snapshot_id)
self.fields['snapshot_source'].choices = ((cg_snapshot_id,
cg_snapshot),)
except Exception:
exceptions.handle(request,
_('Unable to load the specified snapshot.'))
def __init__(self, request, *args, **kwargs):
super(CreateCGroupForm, self).__init__(request, *args, **kwargs)
# populate cgroup_id
cg_snapshot_id = kwargs.get('initial', {}).get('cg_snapshot_id', [])
self.fields['cg_snapshot_id'] = forms.CharField(
widget=forms.HiddenInput(),
initial=cg_snapshot_id)
self.prepare_snapshot_source_field(request, cg_snapshot_id)
def handle(self, request, data):
try:
message = _('Creating consistency group "%s".') % data['name']
cgroup = cinder.volume_cgroup_create_from_source(
request,
data['name'],
cg_snapshot_id=data['cg_snapshot_id'],
description=data['description'])
messages.info(request, message)
return cgroup
except Exception:
redirect = reverse("horizon:project:volumes:index")
msg = _('Unable to create consistency '
'group "%s" from snapshot.') % data['name']
exceptions.handle(request,
msg,
redirect=redirect)

View File

@ -0,0 +1,116 @@
# 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.utils.translation import pgettext_lazy
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from horizon import tables
from openstack_dashboard.api import cinder
from openstack_dashboard import policy
class CreateVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "create_cgroup"
verbose_name = _("Create Consistency Group")
url = "horizon:project:volumes:cg_snapshots:create_cgroup"
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:create"),)
class DeleteVolumeCGSnapshot(policy.PolicyTargetMixin, tables.DeleteAction):
name = "delete_cg_snapshot"
policy_rules = (("volume", "consistencygroup:delete_cgsnapshot"),)
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Delete Snapshot",
u"Delete Snapshots",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Scheduled deletion of Snapshot",
u"Scheduled deletion of Snapshots",
count
)
def delete(self, request, obj_id):
cinder.volume_cg_snapshot_delete(request, obj_id)
class UpdateRow(tables.Row):
ajax = True
def get_data(self, request, cg_snapshot_id):
cg_snapshot = cinder.volume_cg_snapshot_get(request, cg_snapshot_id)
return cg_snapshot
class VolumeCGSnapshotsFilterAction(tables.FilterAction):
def filter(self, table, cg_snapshots, filter_string):
"""Naive case-insensitive search."""
query = filter_string.lower()
return [cg_snapshot for cg_snapshot in cg_snapshots
if query in cg_snapshot.name.lower()]
class CGSnapshotsTable(tables.DataTable):
STATUS_CHOICES = (
("in-use", True),
("available", True),
("creating", None),
("error", False),
)
STATUS_DISPLAY_CHOICES = (
("available",
pgettext_lazy("Current status of Consistency Group Snapshot",
u"Available")),
("in-use",
pgettext_lazy("Current status of Consistency Group Snapshot",
u"In-use")),
("error",
pgettext_lazy("Current status of Consistency Group Snapshot",
u"Error")),
)
name = tables.Column("name",
verbose_name=_("Name"),
link="horizon:project:volumes:"
"cg_snapshots:cg_snapshot_detail")
description = tables.Column("description",
verbose_name=_("Description"),
truncate=40)
status = tables.Column("status",
verbose_name=_("Status"),
status=True,
status_choices=STATUS_CHOICES,
display_choices=STATUS_DISPLAY_CHOICES)
def get_object_id(self, cg_snapshot):
return cg_snapshot.id
class Meta(object):
name = "volume_cg_snapshots"
verbose_name = _("Consistency Group Snapshots")
table_actions = (VolumeCGSnapshotsFilterAction,
DeleteVolumeCGSnapshot)
row_actions = (CreateVolumeCGroup,
DeleteVolumeCGSnapshot,)
row_class = UpdateRow
status_columns = ("status",)
permissions = ['openstack.services.volume']

View File

@ -0,0 +1,34 @@
# 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 tabs
class OverviewTab(tabs.Tab):
name = _("Overview")
slug = "overview"
template_name = ("project/volumes/cg_snapshots/_detail_overview.html")
def get_context_data(self, request):
cg_snapshot = self.tab_group.kwargs['cg_snapshot']
return {"cg_snapshot": cg_snapshot}
def get_redirect_url(self):
return reverse('horizon:project:volumes:cg_snapshots:index')
class CGSnapshotsDetailTabs(tabs.TabGroup):
slug = "cg_snapshots_details"
tabs = (OverviewTab,)

View File

@ -0,0 +1,166 @@
# 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 urlunquote
from mox3.mox import IsA # noqa
from openstack_dashboard.api import cinder
from openstack_dashboard.test import helpers as test
VOLUME_INDEX_URL = reverse('horizon:project:volumes:index')
VOLUME_CG_SNAPSHOTS_TAB_URL = urlunquote(reverse(
'horizon:project:volumes:cg_snapshots_tab'))
class CGroupSnapshotTests(test.TestCase):
@test.create_stubs({cinder: ('volume_cg_snapshot_get',
'volume_cgroup_create_from_source',)})
def test_create_cgroup_from_snapshot(self):
cgroup = self.cinder_consistencygroups.first()
cg_snapshot = self.cinder_cg_snapshots.first()
formData = {'cg_snapshot_id': cg_snapshot.id,
'name': 'test CG SS Create',
'description': 'test desc'}
cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\
AndReturn(cg_snapshot)
cinder.volume_cgroup_create_from_source(
IsA(http.HttpRequest),
formData['name'],
source_cgroup_id=formData['cg_snapshot_id'],
description=formData['description'])\
.AndReturn(cgroup)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cg_snapshots:create_cgroup',
args=[cg_snapshot.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cg_snapshot_get',
'volume_cgroup_create_from_source',)})
def test_create_cgroup_from_snapshot_exception(self):
cg_snapshot = self.cinder_cg_snapshots.first()
new_cg_name = 'test CG SS Create'
formData = {'cg_snapshot_id': cg_snapshot.id,
'name': new_cg_name,
'description': 'test desc'}
cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\
AndReturn(cg_snapshot)
cinder.volume_cgroup_create_from_source(
IsA(http.HttpRequest),
formData['name'],
source_cgroup_id=formData['cg_snapshot_id'],
description=formData['description'])\
.AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cg_snapshots:create_cgroup',
args=[cg_snapshot.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
# There are a bunch of backslashes for formatting in the message from
# the response, so remove them when validating the error message.
self.assertIn('Unable to create consistency group "%s" from snapshot.'
% new_cg_name,
res.cookies.output().replace('\\', ''))
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cg_snapshot_list',
'volume_cg_snapshot_delete',)})
def test_delete_cgroup_snapshot(self):
cg_snapshots = self.cinder_cg_snapshots.list()
cg_snapshot = self.cinder_cg_snapshots.first()
cinder.volume_cg_snapshot_list(IsA(http.HttpRequest)).\
AndReturn(cg_snapshots)
cinder.volume_cg_snapshot_delete(IsA(http.HttpRequest), cg_snapshot.id)
self.mox.ReplayAll()
form_data = {'action': 'volume_cg_snapshots__delete_cg_snapshot__%s'
% cg_snapshot.id}
res = self.client.post(VOLUME_CG_SNAPSHOTS_TAB_URL, form_data,
follow=True)
self.assertEqual(res.status_code, 200)
self.assertIn("Scheduled deletion of Snapshot: %s" % cg_snapshot.name,
[m.message for m in res.context['messages']])
@test.create_stubs({cinder: ('volume_cg_snapshot_list',
'volume_cg_snapshot_delete',)})
def test_delete_cgroup_snapshot_exception(self):
cg_snapshots = self.cinder_cg_snapshots.list()
cg_snapshot = self.cinder_cg_snapshots.first()
cinder.volume_cg_snapshot_list(IsA(http.HttpRequest)).\
AndReturn(cg_snapshots)
cinder.volume_cg_snapshot_delete(IsA(http.HttpRequest),
cg_snapshot.id).\
AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
form_data = {'action': 'volume_cg_snapshots__delete_cg_snapshot__%s'
% cg_snapshot.id}
res = self.client.post(VOLUME_CG_SNAPSHOTS_TAB_URL, form_data,
follow=True)
self.assertEqual(res.status_code, 200)
self.assertIn("Unable to delete snapshot: %s" % cg_snapshot.name,
[m.message for m in res.context['messages']])
@test.create_stubs({cinder: ('volume_cg_snapshot_get',
'volume_cgroup_get',
'volume_type_get',
'volume_list',)})
def test_detail_view(self):
cg_snapshot = self.cinder_cg_snapshots.first()
cgroup = self.cinder_consistencygroups.first()
volume_type = self.cinder_volume_types.first()
volumes = self.cinder_volumes.list()
cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\
AndReturn(cg_snapshot)
cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_type_get(IsA(http.HttpRequest), volume_type.id).\
MultipleTimes().AndReturn(volume_type)
search_opts = {'consistencygroup_id': cgroup.id}
cinder.volume_list(IsA(http.HttpRequest), search_opts=search_opts).\
AndReturn(volumes)
self.mox.ReplayAll()
url = reverse(
'horizon:project:volumes:cg_snapshots:cg_snapshot_detail',
args=[cg_snapshot.id])
res = self.client.get(url)
self.assertNoFormErrors(res)
self.assertEqual(res.status_code, 200)
@test.create_stubs({cinder: ('volume_cg_snapshot_get',)})
def test_detail_view_with_exception(self):
cg_snapshot = self.cinder_cg_snapshots.first()
cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\
AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
url = reverse(
'horizon:project:volumes:cg_snapshots:cg_snapshot_detail',
args=[cg_snapshot.id])
res = self.client.get(url)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)

View File

@ -0,0 +1,26 @@
# 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
from django.conf.urls import url
from openstack_dashboard.dashboards.project.volumes.cg_snapshots import views
urlpatterns = patterns(
'',
url(r'^(?P<cg_snapshot_id>[^/]+)/cg_snapshot_detail/$',
views.DetailView.as_view(),
name='cg_snapshot_detail'),
url(r'^(?P<cg_snapshot_id>[^/]+)/create_cgroup/$',
views.CreateCGroupView.as_view(),
name='create_cgroup'),
)

View File

@ -0,0 +1,138 @@
# 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.api import cinder
from openstack_dashboard.usage import quotas
from openstack_dashboard.dashboards.project.volumes \
.cg_snapshots import forms as cg_snapshot_forms
from openstack_dashboard.dashboards.project.volumes \
.cg_snapshots import tables as cg_snapshot_tables
from openstack_dashboard.dashboards.project.volumes \
.cg_snapshots import tabs as cg_snapshot_tabs
CGROUP_INFO_FIELDS = ("name",
"description")
INDEX_URL = "horizon:project:volumes:index"
class DetailView(tabs.TabView):
tab_group_class = cg_snapshot_tabs.CGSnapshotsDetailTabs
template_name = 'horizon/common/_detail.html'
page_title = "{{ cg_snapshot.name|default:cg_snapshot.id }}"
def get_context_data(self, **kwargs):
context = super(DetailView, self).get_context_data(**kwargs)
cg_snapshot = self.get_data()
table = cg_snapshot_tables.CGSnapshotsTable(self.request)
context["cg_snapshot"] = cg_snapshot
context["url"] = self.get_redirect_url()
context["actions"] = table.render_row_actions(cg_snapshot)
return context
@memoized.memoized_method
def get_data(self):
try:
cg_snapshot_id = self.kwargs['cg_snapshot_id']
cg_snapshot = api.cinder.volume_cg_snapshot_get(self.request,
cg_snapshot_id)
cgroup_id = cg_snapshot.consistencygroup_id
cgroup = api.cinder.volume_cgroup_get(self.request,
cgroup_id)
cg_snapshot.cg_name = cgroup.name
cg_snapshot.volume_type_names = []
for vol_type_id in cgroup.volume_types:
vol_type = api.cinder.volume_type_get(self.request,
vol_type_id)
cg_snapshot.volume_type_names.append(vol_type.name)
cg_snapshot.volume_names = []
search_opts = {'consistencygroup_id': cgroup_id}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
for volume in volumes:
cg_snapshot.volume_names.append(volume.name)
except Exception:
redirect = self.get_redirect_url()
exceptions.handle(self.request,
_('Unable to retrieve consistency group '
'snapshot details.'),
redirect=redirect)
return cg_snapshot
@staticmethod
def get_redirect_url():
return reverse('horizon:project:volumes:index')
def get_tabs(self, request, *args, **kwargs):
cg_snapshot = self.get_data()
return self.tab_group_class(request, cg_snapshot=cg_snapshot, **kwargs)
class CreateCGroupView(forms.ModalFormView):
form_class = cg_snapshot_forms.CreateCGroupForm
modal_header = _("Create Consistency Group")
template_name = 'project/volumes/cg_snapshots/create.html'
submit_url = "horizon:project:volumes:cg_snapshots:create_cgroup"
success_url = reverse_lazy('horizon:project:volumes:cgroups_tab')
page_title = _("Create a Volume Consistency Group from Snapshot")
def get_context_data(self, **kwargs):
context = super(CreateCGroupView, self).get_context_data(**kwargs)
context['cg_snapshot_id'] = self.kwargs['cg_snapshot_id']
args = (self.kwargs['cg_snapshot_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
try:
# get number of volumes we will be creating
cg_snapshot = cinder.volume_cg_snapshot_get(
self.request, context['cg_snapshot_id'])
cgroup_id = cg_snapshot.consistencygroup_id
search_opts = {'consistencygroup_id': cgroup_id}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
num_volumes = len(volumes)
usages = quotas.tenant_limit_usages(self.request)
if usages['volumesUsed'] + num_volumes > \
usages['maxTotalVolumes']:
raise ValueError(_('Unable to create consistency group due to '
'exceeding volume quota limit.'))
else:
usages['numRequestedItems'] = num_volumes
context['usages'] = usages
except ValueError as e:
exceptions.handle(self.request, e.message)
return None
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve consistency '
'group information.'))
return context
def get_initial(self):
return {'cg_snapshot_id': self.kwargs["cg_snapshot_id"]}

View File

@ -28,8 +28,21 @@ class UpdateForm(forms.SelfHandlingForm):
label=_("Description"),
required=False)
def clean(self):
cleaned_data = super(UpdateForm, self).clean()
new_desc = cleaned_data.get('description')
old_desc = self.initial['description']
if len(old_desc):
if len(new_desc) == 0:
error_msg = _("Description is required.")
self._errors['description'] = self.error_class([error_msg])
return cleaned_data
return cleaned_data
def handle(self, request, data):
cgroup_id = self.initial['cgroup_id']
try:
cinder.volume_cgroup_update(request,
cgroup_id,
@ -45,3 +58,160 @@ class UpdateForm(forms.SelfHandlingForm):
exceptions.handle(request,
_('Unable to update volume consistency group.'),
redirect=redirect)
class RemoveVolsForm(forms.SelfHandlingForm):
def handle(self, request, data):
cgroup_id = self.initial['cgroup_id']
name = self.initial['name']
search_opts = {'consistencygroup_id': cgroup_id}
try:
# get list of assigned volumes
assigned_vols = []
volumes = cinder.volume_list(request,
search_opts=search_opts)
for volume in volumes:
assigned_vols.append(volume.id)
assigned_vols_str = ",".join(assigned_vols)
cinder.volume_cgroup_update(request,
cgroup_id,
remove_vols=assigned_vols_str)
message = _('Removing volumes from volume consistency '
'group "%s"') % name
messages.info(request, message)
return True
except Exception:
redirect = reverse("horizon:project:volumes:index")
exceptions.handle(request, _('Errors occurred in removing volumes '
'from consistency group.'),
redirect=redirect)
class DeleteForm(forms.SelfHandlingForm):
delete_volumes = forms.BooleanField(label=_("Delete Volumes"),
required=False)
def handle(self, request, data):
cgroup_id = self.initial['cgroup_id']
name = self.initial['name']
delete_volumes = data['delete_volumes']
try:
cinder.volume_cgroup_delete(request,
cgroup_id,
force=delete_volumes)
message = _('Deleting volume consistency '
'group "%s"') % name
messages.success(request, message)
return True
except Exception:
redirect = reverse("horizon:project:volumes:index")
exceptions.handle(request, _('Errors occurred in deleting '
'consistency group.'),
redirect=redirect)
class CreateSnapshotForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Snapshot Name"))
description = forms.CharField(max_length=255,
widget=forms.Textarea(attrs={'rows': 4}),
label=_("Description"),
required=False)
def __init__(self, request, *args, **kwargs):
super(CreateSnapshotForm, self).__init__(request, *args, **kwargs)
# populate cgroup_id
cgroup_id = kwargs.get('initial', {}).get('cgroup_id', [])
self.fields['cgroup_id'] = forms.CharField(widget=forms.HiddenInput(),
initial=cgroup_id)
def handle(self, request, data):
try:
message = _('Creating consistency group snapshot "%s".') \
% data['name']
snapshot = cinder.volume_cg_snapshot_create(request,
data['cgroup_id'],
data['name'],
data['description'])
messages.info(request, message)
return snapshot
except Exception as e:
redirect = reverse("horizon:project:volumes:index")
msg = _('Unable to create consistency group snapshot.')
if e.code == 413:
msg = _('Requested snapshot would exceed the allowed quota.')
else:
search_opts = {'consistentcygroup_id': data['cgroup_id']}
volumes = cinder.volume_list(request, search_opts=search_opts)
if len(volumes) == 0:
msg = _('Unable to create snapshot. Consistency group '
'must contain volumes.')
exceptions.handle(request,
msg,
redirect=redirect)
class CloneCGroupForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Consistency Group Name"))
description = forms.CharField(max_length=255,
widget=forms.Textarea(attrs={'rows': 4}),
label=_("Description"),
required=False)
cgroup_source = forms.ChoiceField(
label=_("Use a consistency group as source"),
widget=forms.SelectWidget(
attrs={'class': 'image-selector'},
data_attrs=('name'),
transform=lambda x: "%s" % (x.name)),
required=False)
def prepare_cgroup_source_field(self, request, cgroup_id):
try:
cgroup = cinder.volume_cgroup_get(request,
cgroup_id)
self.fields['cgroup_source'].choices = ((cgroup_id,
cgroup),)
except Exception:
exceptions.handle(request, _('Unable to load the specified '
'consistency group.'))
def __init__(self, request, *args, **kwargs):
super(CloneCGroupForm, self).__init__(request, *args, **kwargs)
# populate cgroup_id
cgroup_id = kwargs.get('initial', {}).get('cgroup_id', [])
self.fields['cgroup_id'] = forms.CharField(widget=forms.HiddenInput(),
initial=cgroup_id)
self.prepare_cgroup_source_field(request, cgroup_id)
def handle(self, request, data):
try:
message = _('Creating consistency group "%s".') % data['name']
cgroup = cinder.volume_cgroup_create_from_source(
request,
data['name'],
source_cgroup_id=data['cgroup_id'],
description=data['description'])
messages.info(request, message)
return cgroup
except Exception:
redirect = reverse("horizon:project:volumes:index")
msg = _('Unable to clone consistency group.')
search_opts = {'consistentcygroup_id': data['cgroup_id']}
volumes = cinder.volume_list(request, search_opts=search_opts)
if len(volumes) == 0:
msg = _('Unable to clone empty consistency group.')
exceptions.handle(request,
msg,
redirect=redirect)

View File

@ -10,10 +10,8 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.core.urlresolvers import reverse
from django.utils.translation import pgettext_lazy
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from horizon import exceptions
from horizon import tables
@ -31,36 +29,20 @@ class CreateVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction):
policy_rules = (("volume", "consistencygroup:create"),)
class DeleteVolumeCGroup(policy.PolicyTargetMixin, tables.DeleteAction):
class DeleteVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "deletecg"
verbose_name = _("Delete Consistency Group")
url = "horizon:project:volumes:cgroups:delete"
classes = ("ajax-modal", "btn-danger")
policy_rules = (("volume", "consistencygroup:delete"), )
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Delete Consistency Group",
u"Delete Consistency Groups",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Scheduled deletion of Consistency Group",
u"Scheduled deletion of Consistency Groups",
count
)
def delete(self, request, cgroup_id):
try:
cinder.volume_cgroup_delete(request,
cgroup_id,
force=False)
except Exception:
redirect = reverse("horizon:project:volumes:index")
exceptions.handle(request,
_('Unable to delete consistency group.'),
redirect=redirect)
class RemoveAllVolumes(policy.PolicyTargetMixin, tables.LinkAction):
name = "remove_vols"
verbose_name = _("Remove Volumes from Consistency Group")
url = "horizon:project:volumes:cgroups:remove_volumes"
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:update"), )
class EditVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction):
@ -78,12 +60,51 @@ class ManageVolumes(policy.PolicyTargetMixin, tables.LinkAction):
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:update"),)
def allowed(self, request, cgroup=None):
if hasattr(cgroup, 'status'):
return cgroup.status != 'error'
else:
return False
class CreateSnapshot(policy.PolicyTargetMixin, tables.LinkAction):
name = "create_snapshot"
verbose_name = _("Create Snapshot")
url = "horizon:project:volumes:cgroups:create_snapshot"
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:create_cgsnapshot"),)
def allowed(self, request, cgroup=None):
if hasattr(cgroup, 'status'):
return cgroup.status != 'error'
else:
return False
class CloneCGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "clone_cgroup"
verbose_name = _("Clone Consistency Group")
url = "horizon:project:volumes:cgroups:clone_cgroup"
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:create"),)
def allowed(self, request, cgroup=None):
if hasattr(cgroup, 'status'):
return cgroup.status != 'error'
else:
return False
class UpdateRow(tables.Row):
ajax = True
def get_data(self, request, cgroup_id):
cgroup = cinder.volume_cgroup_get(request, cgroup_id)
try:
cgroup = cinder.volume_cgroup_get_with_vol_type_names(request,
cgroup_id)
except Exception:
exceptions.handle(request, _('Unable to display '
'consistency group.'))
return cgroup
@ -97,6 +118,8 @@ class VolumeCGroupsFilterAction(tables.FilterAction):
def get_volume_types(cgroup):
vtypes_str = ''
if hasattr(cgroup, 'volume_type_names'):
vtypes_str = ",".join(cgroup.volume_type_names)
return vtypes_str
@ -143,6 +166,9 @@ class VolumeCGroupsTable(tables.DataTable):
VolumeCGroupsFilterAction)
row_actions = (ManageVolumes,
EditVolumeCGroup,
CreateSnapshot,
CloneCGroup,
RemoveAllVolumes,
DeleteVolumeCGroup)
row_class = UpdateRow
status_columns = ("status",)

View File

@ -10,7 +10,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import django
from django.core.urlresolvers import reverse
from django import http
from django.utils.http import urlunquote
@ -23,34 +22,36 @@ from openstack_dashboard.test import helpers as test
VOLUME_INDEX_URL = reverse('horizon:project:volumes:index')
VOLUME_CGROUPS_TAB_URL = urlunquote(reverse(
'horizon:project:volumes:cgroups_tab'))
VOLUME_CGROUPS_SNAP_TAB_URL = urlunquote(reverse(
'horizon:project:volumes:cg_snapshots_tab'))
class ConsistencyGroupTests(test.TestCase):
@test.create_stubs({cinder: ('volume_cgroup_create',
'volume_cgroup_list',
@test.create_stubs({cinder: ('extension_supported',
'availability_zone_list',
'volume_type_list',
'volume_type_list_with_qos_associations',
'availability_zone_list',
'extension_supported')})
'volume_cgroup_list',
'volume_cgroup_create')})
def test_create_cgroup(self):
cgroup = self.cinder_consistencygroups.first()
volume_types = self.cinder_volume_types.list()
volume_type_id = self.cinder_volume_types.first().id
az = self.cinder_availability_zones.first().zoneName
formData = {'volume_types': '1',
'name': 'test CG',
'description': 'test desc',
'availability_zone': az}
'availability_zone': az,
'add_vtypes_to_cgroup_role_member': [volume_type_id]}
cinder.volume_type_list(IsA(http.HttpRequest)).\
AndReturn(volume_types)
cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\
AndReturn(volume_types)
cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn(
self.cinder_availability_zones.list())
cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\
.AndReturn(True)
cinder.volume_cgroup_list(IsA(
http.HttpRequest)).\
cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn(
self.cinder_availability_zones.list())
cinder.volume_type_list(IsA(http.HttpRequest)).AndReturn(volume_types)
cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\
AndReturn(volume_types)
cinder.volume_cgroup_list(IsA(http.HttpRequest)).\
AndReturn(self.cinder_consistencygroups.list())
cinder.volume_cgroup_create(
IsA(http.HttpRequest),
@ -64,31 +65,32 @@ class ConsistencyGroupTests(test.TestCase):
url = reverse('horizon:project:volumes:cgroups:create')
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_create',
'volume_cgroup_list',
@test.create_stubs({cinder: ('extension_supported',
'availability_zone_list',
'volume_type_list',
'volume_type_list_with_qos_associations',
'availability_zone_list',
'extension_supported')})
'volume_cgroup_list',
'volume_cgroup_create')})
def test_create_cgroup_exception(self):
volume_types = self.cinder_volume_types.list()
volume_type_id = self.cinder_volume_types.first().id
az = self.cinder_availability_zones.first().zoneName
formData = {'volume_types': '1',
'name': 'test CG',
'description': 'test desc',
'availability_zone': az}
'availability_zone': az,
'add_vtypes_to_cgroup_role_member': [volume_type_id]}
cinder.volume_type_list(IsA(http.HttpRequest)).\
AndReturn(volume_types)
cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\
AndReturn(volume_types)
cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn(
self.cinder_availability_zones.list())
cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\
.AndReturn(True)
cinder.volume_cgroup_list(IsA(
http.HttpRequest)).\
cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn(
self.cinder_availability_zones.list())
cinder.volume_type_list(IsA(http.HttpRequest)).AndReturn(volume_types)
cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\
AndReturn(volume_types)
cinder.volume_cgroup_list(IsA(http.HttpRequest)).\
AndReturn(self.cinder_consistencygroups.list())
cinder.volume_cgroup_create(
IsA(http.HttpRequest),
@ -101,29 +103,65 @@ class ConsistencyGroupTests(test.TestCase):
url = reverse('horizon:project:volumes:cgroups:create')
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
self.assertIn("Unable to create consistency group.",
res.cookies.output())
@test.create_stubs({cinder: ('volume_cgroup_list_with_vol_type_names',
@test.create_stubs({cinder: ('volume_cgroup_get',
'volume_cgroup_delete')})
def test_delete_cgroup(self):
cgroups = self.cinder_consistencygroups.list()
cgroup = self.cinder_consistencygroups.first()
cinder.volume_cgroup_list_with_vol_type_names(IsA(http.HttpRequest)).\
AndReturn(cgroups)
cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_delete(IsA(http.HttpRequest), cgroup.id,
force=False)
if django.VERSION < (1, 9):
cinder.volume_cgroup_list_with_vol_type_names(
IsA(http.HttpRequest)).AndReturn(cgroups)
self.mox.ReplayAll()
formData = {'action': 'volume_cgroups__deletecg__%s' % cgroup.id}
res = self.client.post(VOLUME_CGROUPS_TAB_URL, formData, follow=True)
self.assertIn("Scheduled deletion of Consistency Group: cg_1",
[m.message for m in res.context['messages']])
url = reverse('horizon:project:volumes:cgroups:delete',
args=[cgroup.id])
res = self.client.post(url)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_get',
'volume_cgroup_delete')})
def test_delete_cgroup_force_flag(self):
cgroup = self.cinder_consistencygroups.first()
formData = {'delete_volumes': True}
cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_delete(IsA(http.HttpRequest), cgroup.id,
force=True)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:delete',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_get',
'volume_cgroup_delete')})
def test_delete_cgroup_exception(self):
cgroup = self.cinder_consistencygroups.first()
formData = {'delete_volumes': False}
cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_delete(IsA(http.HttpRequest),
cgroup.id,
force=False).\
AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:delete',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
@ -149,6 +187,7 @@ class ConsistencyGroupTests(test.TestCase):
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
@ -174,6 +213,7 @@ class ConsistencyGroupTests(test.TestCase):
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
@ -197,6 +237,7 @@ class ConsistencyGroupTests(test.TestCase):
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
@ -219,7 +260,7 @@ class ConsistencyGroupTests(test.TestCase):
url = reverse('horizon:project:volumes:cgroups:update',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_get',)})
@ -234,5 +275,48 @@ class ConsistencyGroupTests(test.TestCase):
url = reverse('horizon:project:volumes:cgroups:detail',
args=[cgroup.id])
res = self.client.get(url)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cg_snapshot_create',)})
def test_create_snapshot(self):
cgroup = self.cinder_consistencygroups.first()
cg_snapshot = self.cinder_cg_snapshots.first()
formData = {'cgroup_id': cgroup.id,
'name': 'test CG Snapshot',
'description': 'test desc'}
cinder.volume_cg_snapshot_create(
IsA(http.HttpRequest),
formData['cgroup_id'],
formData['name'],
formData['description'])\
.AndReturn(cg_snapshot)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:create_snapshot',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_CGROUPS_SNAP_TAB_URL)
@test.create_stubs({cinder: ('volume_cgroup_get',
'volume_cgroup_create_from_source',)})
def test_create_clone(self):
cgroup = self.cinder_consistencygroups.first()
formData = {'cgroup_id': cgroup.id,
'name': 'test CG Clone',
'description': 'test desc'}
cinder.volume_cgroup_create_from_source(
IsA(http.HttpRequest),
formData['name'],
source_cgroup_id=formData['cgroup_id'],
description=formData['description'])\
.AndReturn(cgroup)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:clone_cgroup',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_CGROUPS_TAB_URL)

View File

@ -22,9 +22,21 @@ urlpatterns = [
url(r'^(?P<cgroup_id>[^/]+)/update/$',
views.UpdateView.as_view(),
name='update'),
url(r'^(?P<cgroup_id>[^/]+)/remove_volumese/$',
views.RemoveVolumesView.as_view(),
name='remove_volumes'),
url(r'^(?P<cgroup_id>[^/]+)/delete/$',
views.DeleteView.as_view(),
name='delete'),
url(r'^(?P<cgroup_id>[^/]+)/manage/$',
views.ManageView.as_view(),
name='manage'),
url(r'^(?P<cgroup_id>[^/]+)/create_snapshot/$',
views.CreateSnapshotView.as_view(),
name='create_snapshot'),
url(r'^(?P<cgroup_id>[^/]+)/clone_cgroup/$',
views.CloneCGroupView.as_view(),
name='clone_cgroup'),
url(r'^(?P<cgroup_id>[^/]+)$',
views.DetailView.as_view(),
name='detail'),

View File

@ -22,6 +22,7 @@ from horizon import workflows
from openstack_dashboard import api
from openstack_dashboard.api import cinder
from openstack_dashboard.usage import quotas
from openstack_dashboard.dashboards.project.volumes \
.cgroups import workflows as vol_cgroup_workflows
@ -50,7 +51,7 @@ class UpdateView(forms.ModalFormView):
form_class = vol_cgroup_forms.UpdateForm
success_url = reverse_lazy('horizon:project:volumes:index')
submit_url = "horizon:project:volumes:cgroups:update"
submit_label = modal_header
submit_label = _("Submit")
page_title = modal_header
def get_initial(self):
@ -78,6 +79,72 @@ class UpdateView(forms.ModalFormView):
return self._object
class RemoveVolumesView(forms.ModalFormView):
template_name = 'project/volumes/cgroups/remove_vols.html'
modal_header = _("Remove Volumes from Consistency Group")
form_class = vol_cgroup_forms.RemoveVolsForm
success_url = reverse_lazy('horizon:project:volumes:index')
submit_url = "horizon:project:volumes:cgroups:remove_volumes"
submit_label = _("Submit")
page_title = modal_header
def get_initial(self):
cgroup = self.get_object()
return {'cgroup_id': self.kwargs["cgroup_id"],
'name': cgroup.name}
def get_context_data(self, **kwargs):
context = super(RemoveVolumesView, self).get_context_data(**kwargs)
context['cgroup_id'] = self.kwargs['cgroup_id']
args = (self.kwargs['cgroup_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_object(self):
cgroup_id = self.kwargs['cgroup_id']
try:
self._object = cinder.volume_cgroup_get(self.request, cgroup_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve consistency group '
'details.'),
redirect=reverse(INDEX_URL))
return self._object
class DeleteView(forms.ModalFormView):
template_name = 'project/volumes/cgroups/delete.html'
modal_header = _("Delete Consistency Group")
form_class = vol_cgroup_forms.DeleteForm
success_url = reverse_lazy('horizon:project:volumes:index')
submit_url = "horizon:project:volumes:cgroups:delete"
submit_label = modal_header
page_title = modal_header
def get_initial(self):
cgroup = self.get_object()
return {'cgroup_id': self.kwargs["cgroup_id"],
'name': cgroup.name}
def get_context_data(self, **kwargs):
context = super(DeleteView, self).get_context_data(**kwargs)
context['cgroup_id'] = self.kwargs['cgroup_id']
args = (self.kwargs['cgroup_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_object(self):
cgroup_id = self.kwargs['cgroup_id']
try:
self._object = cinder.volume_cgroup_get(self.request, cgroup_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve consistency group '
'details.'),
redirect=reverse(INDEX_URL))
return self._object
class ManageView(workflows.WorkflowView):
workflow_class = vol_cgroup_workflows.UpdateCGroupWorkflow
@ -105,6 +172,94 @@ class ManageView(workflows.WorkflowView):
'vtypes': getattr(cgroup, "volume_types")}
class CreateSnapshotView(forms.ModalFormView):
form_class = vol_cgroup_forms.CreateSnapshotForm
modal_header = _("Create Consistency Group Snapshot")
template_name = 'project/volumes/cgroups/create_snapshot.html'
submit_label = _("Create Snapshot")
submit_url = "horizon:project:volumes:cgroups:create_snapshot"
success_url = reverse_lazy('horizon:project:volumes:cg_snapshots_tab')
page_title = modal_header
def get_context_data(self, **kwargs):
context = super(CreateSnapshotView, self).get_context_data(**kwargs)
context['cgroup_id'] = self.kwargs['cgroup_id']
args = (self.kwargs['cgroup_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
try:
# get number of snapshots we will be creating
search_opts = {'consistencygroup_id': context['cgroup_id']}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
num_volumes = len(volumes)
usages = quotas.tenant_limit_usages(self.request)
if usages['snapshotsUsed'] + num_volumes > \
usages['maxTotalSnapshots']:
raise ValueError(_('Unable to create snapshots due to '
'exceeding snapshot quota limit.'))
else:
usages['numRequestedItems'] = num_volumes
context['usages'] = usages
except ValueError as e:
exceptions.handle(self.request, e.message)
return None
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve consistency '
'group information.'))
return context
def get_initial(self):
return {'cgroup_id': self.kwargs["cgroup_id"]}
class CloneCGroupView(forms.ModalFormView):
form_class = vol_cgroup_forms.CloneCGroupForm
modal_header = _("Clone Consistency Group")
template_name = 'project/volumes/cgroups/clone_cgroup.html'
submit_label = _("Clone Consistency Group")
submit_url = "horizon:project:volumes:cgroups:clone_cgroup"
success_url = reverse_lazy('horizon:project:volumes:cgroups_tab')
page_title = modal_header
def get_context_data(self, **kwargs):
context = super(CloneCGroupView, self).get_context_data(**kwargs)
context['cgroup_id'] = self.kwargs['cgroup_id']
args = (self.kwargs['cgroup_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
try:
# get number of volumes we will be creating
cgroup_id = context['cgroup_id']
search_opts = {'consistencygroup_id': cgroup_id}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
num_volumes = len(volumes)
usages = quotas.tenant_limit_usages(self.request)
if usages['volumesUsed'] + num_volumes > \
usages['maxTotalVolumes']:
raise ValueError(_('Unable to create consistency group due to '
'exceeding volume quota limit.'))
else:
usages['numRequestedItems'] = num_volumes
context['usages'] = usages
except ValueError as e:
exceptions.handle(self.request, e.message)
return None
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve consistency '
'group information.'))
return context
def get_initial(self):
return {'cgroup_id': self.kwargs["cgroup_id"]}
class DetailView(tabs.TabView):
tab_group_class = vol_cgroup_tabs.CGroupsDetailTabs
template_name = 'horizon/common/_detail.html'

View File

@ -15,7 +15,6 @@ from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import messages
from horizon import workflows
from openstack_dashboard import api
@ -100,10 +99,14 @@ class AddCGroupInfoAction(workflows.Action):
if cgroups is not None and name is not None:
for cgroup in cgroups:
if cgroup.name.lower() == name.lower():
# ensure new name has reasonable length
formatted_name = name
if len(name) > 20:
formatted_name = name[:14] + "..." + name[-3:]
raise forms.ValidationError(
_('The name "%s" is already used by '
'another consistency group.')
% name
% formatted_name
)
return cleaned_data
@ -136,19 +139,25 @@ class AddVolumeTypesToCGroupAction(workflows.MembershipAction):
except Exception:
exceptions.handle(request, err_msg)
vtype_names = []
for vtype in vtypes:
if vtype.name not in vtype_names:
vtype_names.append(vtype.name)
vtype_names.sort()
self.fields[field_name].choices = \
[(vtype_name, vtype_name) for vtype_name in vtype_names]
vtype_list = [(vtype.id, vtype.name)
for vtype in vtypes]
self.fields[field_name].choices = vtype_list
class Meta(object):
name = _("Manage Volume Types")
slug = "add_vtypes_to_cgroup"
def clean(self):
cleaned_data = super(AddVolumeTypesToCGroupAction, self).clean()
volume_types = cleaned_data.get('add_vtypes_to_cgroup_role_member')
if not volume_types:
raise forms.ValidationError(
_('At least one volume type must be assigned '
'to a consistency group.')
)
return cleaned_data
class AddVolTypesToCGroupStep(workflows.UpdateMembersStep):
action_class = AddVolumeTypesToCGroupAction
@ -198,15 +207,28 @@ class AddVolumesToCGroupAction(workflows.MembershipAction):
volumes = cinder.volume_list(request)
for volume in volumes:
if volume.volume_type in vtype_names:
cgroup_id = None
vol_is_available = False
in_this_cgroup = False
if hasattr(volume, 'consistencygroup_id'):
if volume.consistencygroup_id == \
self.initial['cgroup_id']:
# this vol already belongs to a CG.
# only include it here if it belongs to this CG
cgroup_id = volume.consistencygroup_id
if not cgroup_id:
# put this vol in the available list
vol_is_available = True
elif cgroup_id == self.initial['cgroup_id']:
# put this vol in the assigned to CG list
vol_is_available = True
in_this_cgroup = True
if vol_is_available:
vol_list.append({'volume_name': volume.name,
'volume_id': volume.id,
'in_cgroup': in_this_cgroup,
'is_duplicate': False})
sorted_vol_list = sorted(vol_list, key=lambda k: k['volume_name'])
# mark any duplicate volume names
@ -228,9 +250,9 @@ class AddVolumesToCGroupAction(workflows.MembershipAction):
" [" + volume['volume_id'] + "]"
else:
entry = volume['volume_name']
available_vols.append((entry, entry))
available_vols.append((volume['volume_id'], entry))
if volume['in_cgroup']:
assigned_vols.append(entry)
assigned_vols.append(volume['volume_id'])
except Exception:
exceptions.handle(request, err_msg)
@ -291,7 +313,7 @@ class CreateCGroupWorkflow(workflows.Workflow):
for selected_vol_type in selected_vol_types:
if not invalid_backend:
for vol_type in vol_types:
if selected_vol_type == vol_type.name:
if selected_vol_type == vol_type.id:
if hasattr(vol_type, "extra_specs"):
vol_type_backend = \
vol_type.extra_specs['volume_backend_name']
@ -319,7 +341,7 @@ class CreateCGroupWorkflow(workflows.Workflow):
cinder.volume_cgroup_create(
request,
vtypes_str,
name=context['name'],
context['name'],
description=context['description'],
availability_zone=context['availability_zone'])
except Exception:
@ -331,11 +353,11 @@ class CreateCGroupWorkflow(workflows.Workflow):
class UpdateCGroupWorkflow(workflows.Workflow):
slug = "create_cgroup"
slug = "update_cgroup"
name = _("Add/Remove Consistency Group Volumes")
finalize_button_name = _("Edit Consistency Group")
success_message = _('Edit consistency group "%s".')
failure_message = _('Unable to edit consistency group')
finalize_button_name = _("Submit")
success_message = _('Updated volumes for consistency group "%s".')
failure_message = _('Unable to update volumes for consistency group')
success_url = INDEX_URL
default_steps = (AddVolumesToCGroupStep,)
@ -351,23 +373,8 @@ class UpdateCGroupWorkflow(workflows.Workflow):
for volume in volumes:
selected = False
for selection in selected_volumes:
if " [" in selection:
# handle duplicate volume names
sel = selection.split(" [")
sel_vol_name = sel[0]
sel_vol_id = sel[1].split("]")[0]
else:
sel_vol_name = selection
sel_vol_id = None
if volume.name == sel_vol_name:
if sel_vol_id:
if sel_vol_id == volume.id:
if selection == volume.id:
selected = True
else:
selected = True
if selected:
break
if selected:
@ -381,43 +388,24 @@ class UpdateCGroupWorkflow(workflows.Workflow):
# ensure this volume is not in our consistency group
if hasattr(volume, 'consistencygroup_id'):
if volume.consistencygroup_id == cgroup_id:
# remove from this CG
remove_vols.append(volume.id)
add_vols_str = ",".join(add_vols)
remove_vols_str = ",".join(remove_vols)
if not add_vols_str and not remove_vols_str:
# nothing to change
return True
cinder.volume_cgroup_update(request,
cgroup_id,
name=context['name'],
add_vols=add_vols_str,
remove_vols=remove_vols_str)
# before returning, ensure all new volumes are correctly assigned
self._verify_changes(request, cgroup_id, add_vols, remove_vols)
message = _('Updating volume consistency '
'group "%s"') % context['name']
messages.info(request, message)
except Exception:
exceptions.handle(request, _('Unable to edit consistency group.'))
# error message supplied by form
return False
return True
def _verify_changes(self, request, cgroup_id, add_vols, remove_vols):
search_opts = {'consistencygroup_id': cgroup_id}
done = False
while not done:
done = True
volumes = cinder.volume_list(request,
search_opts=search_opts)
assigned_vols = []
for volume in volumes:
assigned_vols.append(volume.id)
for add_vol in add_vols:
if add_vol not in assigned_vols:
done = False
for remove_vol in remove_vols:
if remove_vol in assigned_vols:
done = False

View File

@ -24,8 +24,10 @@ from openstack_dashboard import policy
from openstack_dashboard.dashboards.project.volumes.backups \
import tables as backups_tables
from openstack_dashboard.dashboards.project.volumes.cg_snapshots \
import tables as cg_snapshots_tables
from openstack_dashboard.dashboards.project.volumes.cgroups \
import tables as vol_cgroup_tables
import tables as cgroup_tables
from openstack_dashboard.dashboards.project.volumes.snapshots \
import tables as vol_snapshot_tables
from openstack_dashboard.dashboards.project.volumes.volumes \
@ -206,9 +208,9 @@ class BackupsTab(PagedTableMixin, tabs.TableTab, VolumeTableMixIn):
return backups
class CGroupsTab(tabs.TableTab, VolumeTableMixIn):
table_classes = (vol_cgroup_tables.VolumeCGroupsTable,)
name = _("Volume Consistency Groups")
class CGroupsTab(tabs.TableTab):
table_classes = (cgroup_tables.VolumeCGroupsTable,)
name = _("Consistency Groups")
slug = "cgroups_tab"
template_name = ("horizon/common/_detail_table.html")
preload = False
@ -223,8 +225,7 @@ class CGroupsTab(tabs.TableTab, VolumeTableMixIn):
try:
cgroups = api.cinder.volume_cgroup_list_with_vol_type_names(
self.request)
for cgroup in cgroups:
setattr(cgroup, '_volume_tab', self.tab_group.tabs[0])
except Exception:
cgroups = []
exceptions.handle(self.request, _("Unable to retrieve "
@ -232,7 +233,32 @@ class CGroupsTab(tabs.TableTab, VolumeTableMixIn):
return cgroups
class CGSnapshotsTab(tabs.TableTab):
table_classes = (cg_snapshots_tables.CGSnapshotsTable,)
name = _("Consistency Group Snapshots")
slug = "cg_snapshots_tab"
template_name = ("horizon/common/_detail_table.html")
preload = False
def allowed(self, request):
return policy.check(
(("volume", "consistencygroup:get_all_cgsnapshots"),),
request
)
def get_volume_cg_snapshots_data(self):
try:
cg_snapshots = api.cinder.volume_cg_snapshot_list(
self.request)
except Exception:
cg_snapshots = []
exceptions.handle(self.request, _("Unable to retrieve "
"volume consistency group "
"snapshots."))
return cg_snapshots
class VolumeAndSnapshotTabs(tabs.TabGroup):
slug = "volumes_and_snapshots"
tabs = (VolumeTab, SnapshotTab, BackupsTab, CGroupsTab)
tabs = (VolumeTab, SnapshotTab, BackupsTab, CGroupsTab, CGSnapshotsTab)
sticky = True

View File

@ -0,0 +1,63 @@
{% load i18n horizon humanize bootstrap %}
<h3>{% block head %}{% trans "Volume Limits" %}{% endblock %}</h3>
<div class="quota_title">
<div class="pull-left">
<strong>{% trans "Total Gibibytes" %}</strong>
<span>({% block gigabytes_used %}{{ usages.gigabytesUsed|intcomma }}{% endblock %} {% trans "GiB" %})</span>
</div>
<span class="pull-right">{{ usages.maxTotalVolumeGigabytes|intcomma|quota:_("GiB") }}</span>
</div>
{{ minifyspace }}
<div id="quota_size"
data-progress-indicator-for="id_size"
data-quota-limit="{{ usages.maxTotalVolumeGigabytes }}"
data-quota-used={% block gigabytes_used_progress %}"{{ usages.gigabytesUsed }}"{% endblock %}
class="quota_bar">
{% widthratio usages.gigabytesUsed usages.maxTotalVolumeGigabytes 100 as gigabytes_percent %}
{% bs_progress_bar gigabytes_percent 0 %}
</div>
{{ endminifyspace }}
<div class="quota_title">
<div class="pull-left">
<strong>{% block type_title %}{% trans "Number of Volumes" %}{% endblock %}</strong>
<span>({% block used %}{{ usages.volumesUsed|intcomma }}{% endblock %})</span>
</div>
<span class="pull-right">{% block total %}{{ usages.maxTotalVolumes|intcomma|quota }}{% endblock %}</span>
</div>
{{ minifyspace }}
<div id={% block type_id %}"quota_volumes"{% endblock %}
data-quota-limit={% block total_progress %}"{{ usages.maxTotalVolumes }}"{% endblock %}
data-quota-used={% block used_progress %}"{{ usages.volumesUsed }}"{% endblock %}
class="quota_bar">
{% widthratio usages.volumesUsed usages.maxTotalVolumes 100 as volumes_percent %}
{% if usages.numRequestedItems %}
{% widthratio 100 usages.maxTotalVolumes usages.numRequestedItems as single_step %}
{% else %}
{% widthratio 100 usages.maxTotalVolumes 1 as single_step %}
{% endif %}
{% bs_progress_bar volumes_percent single_step %}
</div>
{{ endminifyspace }}
<script type="text/javascript" charset="utf-8">
if(typeof horizon.Quota !== 'undefined') {
horizon.Quota.init();
} else {
addHorizonLoadEvent(function() {
horizon.Quota.init();
});
}
if(typeof horizon.Volumes !== 'undefined') {
horizon.Volumes.initWithTypes({{ volume_types|safe|default:"{}" }});
} else {
addHorizonLoadEvent(function() {
horizon.Volumes.initWithTypes({{ volume_types|safe|default:"{}" }});
});
}
</script>

View File

@ -0,0 +1,9 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<div class="quota-dynamic">
<p>{% blocktrans %}Create a Consistency Group that will contain newly created volumes cloned from each of the snapshots in the source Consistency Group Snapshot.{% endblocktrans %}</p>
{% include "project/volumes/_volume_limits.html" with usages=usages %}
</div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% load i18n sizeformat parse_date %}
<div class="detail">
<dl class="dl-horizontal">
<dt>{% trans "Name" %}</dt>
<dd>{{ cg_snapshot.name }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ cg_snapshot.id }}</dd>
{% if cg_snapshot.description %}
<dt>{% trans "Description" %}</dt>
<dd>{{ cg_snapshot.description }}</dd>
{% endif %}
<dt>{% trans "Status" %}</dt>
<dd>{{ cg_snapshot.status|capfirst }}</dd>
<dt>{% trans "Consistency Group" %}</dt>
<dd>
<a href="{% url 'horizon:project:volumes:cgroups:detail' cg_snapshot.consistencygroup_id %}">
{% if cg_snapshot.cg_name %}
{{ cg_snapshot.cg_name }}
{% else %}
{{ cg_snapshot.consistencygroup_id }}
{% endif %}
</a>
</dd>
</dl>
<h4>{% trans "Snapshot Volume Types" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_type_names in cg_snapshot.volume_type_names %}
<dd>{{ vol_type_names }}</dd>
{% endfor %}
</dl>
<h4>{% trans "Snapshot Volumes" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_names in cg_snapshot.volume_names %}
<dd>{{ vol_names }}</dd>
{% empty %}
<dd>
<em>{% trans "No assigned volumes" %}</em>
</dd>
{% endfor %}
</dl>
</div>

View File

@ -0,0 +1,7 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block modal-body-right %}
<p>{% trans "Modify the name and description of a volume consistency group snapshot." %}</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/volumes/cg_snapshots/_create.html' %}
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/volumes/cg_snapshots/_update.html' %}
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<div class="quota-dynamic">
<p>{% blocktrans %}Clone each of the volumes in the source Consistency Group, and then add them to a newly created Consistency Group.{% endblocktrans %}</p>
{% include "project/volumes/_volume_limits.html" with usages=usages snapshot_quota=False %}
</div>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<div class="quota-dynamic">
<p>{% blocktrans %}Create a snapshot for each volume contained in the Consistency Group.{% endblocktrans %}</p>
<p>{% blocktrans %}Snapshots can only be created for Consistency Groups that contain volumes.{% endblocktrans %}</p>
{% include "project/volumes/cgroups/_snapshot_limits.html" with usages=usages snapshot_quota=True %}
</div>
{% endblock %}

View File

@ -3,8 +3,7 @@
{% block title %}{{ page_title }}{% endblock %}
{% block modal-body-right %}
<p>{% trans "Volume consistency groups can only be deleted after all the volumes they contain are either deleted or unassigned." %}</p>
<p>{% trans "The default action for deleting a consistency group is to first disassociate all associated volumes." %}</p>
<p>{% trans "Check the &quot;Delete Volumes&quot; box to also delete the volumes associated with this consistency group." %}</p>
<p>{% trans "Volume consistency groups can not be deleted if they contain volumes." %}</p>
<p>{% trans "Check the &quot;Delete Volumes&quot; box to also delete any volumes associated with this consistency group." %}</p>
<p>{% trans "Note that a volume can not be deleted if it is &quot;attached&quot; or has any dependent snapshots." %}</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block modal-body %}
<p>{% trans "This action will unassign all volumes that are currently contained in this consistency group." %}</p>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "project/volumes/_volume_limits.html" %}
{% load i18n horizon humanize %}
{% block title %}
{% trans "From here you can create a snapshot of a volume." %}
{% endblock %}
{% block head %}
{% trans "Snapshot Limits" %}
{% endblock %}
{% block gigabytes_used %}
{{ usages.totalGigabytesUsed|intcomma }}
{% endblock %}
{% block gigabytes_used_progress %}
"{{ usages.totalGigabytesUsed }}"
{% endblock %}
{% block type_title %}
{% trans "Number of Snapshots" %}
{% endblock %}
{% block used %}
{{ usages.snapshotsUsed|intcomma }}
{% endblock %}
{% block total %}
{{ usages.maxTotalSnapshots|intcomma|quota }}
{% endblock %}
{% block type_id %}
"quota_snapshots"
{% endblock %}
{% block total_progress %}
"{{ usages.maxTotalSnapshots }}"
{% endblock %}
{% block used_progress %}
"{{ usages.snapshotsUsed }}"
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/volumes/cgroups/_clone_cgroup.html' %}
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/volumes/cgroups/_create_snapshot.html' %}
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/volumes/cgroups/_remove_vols.html' %}
{% endblock %}

View File

@ -47,7 +47,11 @@
data-quota-used={% block used_progress %}"{{ usages.volumesUsed }}"{% endblock %}
class="quota_bar">
{% widthratio usages.volumesUsed usages.maxTotalVolumes 100 as volumes_percent %}
{% if usages.numRequestedItems %}
{% widthratio 100 usages.maxTotalVolumes usages.numRequestedItems as single_step %}
{% else %}
{% widthratio 100 usages.maxTotalVolumes 1 as single_step %}
{% endif %}
{% bs_progress_bar volumes_percent single_step %}
</div>
{{ endminifyspace }}

View File

@ -17,6 +17,8 @@ from django.conf.urls import url
from openstack_dashboard.dashboards.project.volumes.backups \
import urls as backups_urls
from openstack_dashboard.dashboards.project.volumes.cg_snapshots \
import urls as cg_snapshots_urls
from openstack_dashboard.dashboards.project.volumes.cgroups \
import urls as cgroup_urls
from openstack_dashboard.dashboards.project.volumes.snapshots \
@ -35,8 +37,21 @@ urlpatterns = [
views.IndexView.as_view(), name='backups_tab'),
url(r'^\?tab=volumes_and_snapshots__cgroups_tab$',
views.IndexView.as_view(), name='cgroups_tab'),
url(r'', include(volume_urls, namespace='volumes')),
url(r'backups/', include(backups_urls, namespace='backups')),
url(r'snapshots/', include(snapshot_urls, namespace='snapshots')),
url(r'cgroups/', include(cgroup_urls, namespace='cgroups')),
url(r'^\?tab=volumes_and_snapshots__cg_snapshots_tab$',
views.IndexView.as_view(), name='cg_snapshots_tab'),
url(r'', include(
volume_urls,
namespace='volumes')),
url(r'backups/', include(
backups_urls,
namespace='backups')),
url(r'snapshots/', include(
snapshot_urls,
namespace='snapshots')),
url(r'cgroups/', include(
cgroup_urls,
namespace='cgroups')),
url(r'cg_snapshots/', include(
cg_snapshots_urls,
namespace='cg_snapshots')),
]

View File

@ -111,6 +111,10 @@ class DeleteVolume(VolumePolicyTargetMixin, tables.DeleteAction):
def allowed(self, request, volume=None):
if volume:
# Can't delete volume if part of consistency group
if getattr(volume, 'consistencygroup_id', None):
return False
return (volume.status in DELETABLE_STATES and
not getattr(volume, 'has_snapshot', False))
return True

View File

@ -356,15 +356,13 @@ class CinderApiTests(test.APITestCase):
def test_cgroup_list_with_vol_type_names(self):
cgroups = self.cinder_consistencygroups.list()
cgroup = self.cinder_consistencygroups.first()
volume_types_list = self.cinder_volume_types.list()
cinderclient = self.stub_cinderclient()
cinderclient.consistencygroups = self.mox.CreateMockAnything()
cinderclient.consistencygroups.list(search_opts=None).\
AndReturn(cgroups)
cinderclient.volume_types = self.mox.CreateMockAnything()
for volume_types in volume_types_list:
cinderclient.volume_types.get(cgroup.id).AndReturn(volume_types)
cinderclient.volume_types.list().AndReturn(volume_types_list)
self.mox.ReplayAll()
api_cgroups = api.cinder.volume_cgroup_list_with_vol_type_names(
self.request)
@ -373,6 +371,29 @@ class CinderApiTests(test.APITestCase):
self.assertEqual(volume_types_list[i].name,
api_cgroups[0].volume_type_names[i])
def test_cgsnapshot_list(self):
cgsnapshots = self.cinder_cg_snapshots.list()
cinderclient = self.stub_cinderclient()
cinderclient.cgsnapshots = self.mox.CreateMockAnything()
cinderclient.cgsnapshots.list(search_opts=None).\
AndReturn(cgsnapshots)
self.mox.ReplayAll()
api_cgsnapshots = api.cinder.volume_cg_snapshot_list(self.request)
self.assertEqual(len(cgsnapshots), len(api_cgsnapshots))
def test_cgsnapshot_get(self):
cgsnapshot = self.cinder_cg_snapshots.first()
cinderclient = self.stub_cinderclient()
cinderclient.cgsnapshots = self.mox.CreateMockAnything()
cinderclient.cgsnapshots.get(cgsnapshot.id).AndReturn(cgsnapshot)
self.mox.ReplayAll()
api_cgsnapshot = api.cinder.volume_cg_snapshot_get(self.request,
cgsnapshot.id)
self.assertEqual(api_cgsnapshot.name, cgsnapshot.name)
self.assertEqual(api_cgsnapshot.description, cgsnapshot.description)
self.assertEqual(api_cgsnapshot.consistencygroup_id,
cgsnapshot.consistencygroup_id)
class CinderApiVersionTests(test.TestCase):

View File

@ -13,6 +13,7 @@
# under the License.
from cinderclient.v2 import availability_zones
from cinderclient.v2 import cgsnapshots
from cinderclient.v2 import consistencygroups
from cinderclient.v2 import pools
from cinderclient.v2 import qos_specs
@ -49,6 +50,7 @@ def data(TEST):
TEST.cinder_pools = utils.TestDataContainer()
TEST.cinder_consistencygroups = utils.TestDataContainer()
TEST.cinder_cgroup_volumes = utils.TestDataContainer()
TEST.cinder_cg_snapshots = utils.TestDataContainer()
# Services
service_1 = services.Service(services.ServiceManager(None), {
@ -148,7 +150,9 @@ def data(TEST):
{'id': u'1',
'name': u'vol_type_1',
'description': 'type 1 description',
'extra_specs': {'foo': 'bar'}})
'extra_specs': {'foo': 'bar',
'volume_backend_name':
'backend_1'}})
vol_type2 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
{'id': u'2',
'name': u'vol_type_2',
@ -419,3 +423,12 @@ def data(TEST):
'consistencygroup_id': u'1'})
TEST.cinder_cgroup_volumes.add(api.cinder.Volume(
volume_for_consistency_group))
# volume consistency group snapshots
cg_snapshot_1 = cgsnapshots.Cgsnapshot(
cgsnapshots.CgsnapshotManager(None),
{'id': u'1',
'name': u'cg_ss_1',
'description': 'cg_ss 1 description',
'consistencygroup_id': u'1'})
TEST.cinder_cg_snapshots.add(cg_snapshot_1)

View File

@ -0,0 +1,13 @@
---
features:
- >
[`blueprint cinder-consistency-groups <https://blueprints.launchpad.net/horizon/+spec/cinder-consistency-groups>`_]
This feature adds 2 new tabs to the Project Volumes panel. The first tab will display
Consistency Groups, and the second tab will display Consistency Group Snapshots.
Consistency Groups (CG) contain existing volumes, and allow the user to perform
actions on the volumes in one step. Actions include: create/update/delete CGs,
snapshot all volumes in a CG, clone all volumes in a CG, and create a new CG and
volumes from a CG snapshot.
Policies associated with Consistency Groups exist in the Cinder policy file, and
by default, all actions are disabled.