Add Horizon support for volume consistency groups

Cinder has added support for creating and managing volume consistency
groups. This first patch adds this functionality into Horizon.
Subsequent patches will add features to utilize these consistency
groups - such as for creating snapshots.

Background/setup info:
This feature provides a Horizon interface to the Cinder consistency
groups (CG) API.   CGs allow a user to group a set of volumes together,
and then perform actions on all of the volumes in one command.

For example:
- Create CG snapshot. This will pause all volume I/O and create a
  snapshot for each volume in the CG at the same point in time.
- Create CG from another CG. This will clone a new set of volumes based
  on the current state of all volumes in the original CG.
- Create CG from CG snapshot. This will clone new set of volumes based
  on the saved state of all snapshots from the original CG snapshot.

This patch is limited to just creating consistency groups. A second
and final patch will add the snapshot features.

To run the patch, you will need to enable some consistency group
policies, which currently are defaulted to "none", which means they
are disabled.

The policies are set in /etc/cinder/policy.json - they need to be set
to the following:

    "consistencygroup:create" : "rule:admin_or_owner",
    "consistencygroup:delete": "rule:admin_or_owner",
    "consistencygroup:update": "rule:admin_or_owner",
    "consistencygroup:get": "rule:admin_or_owner",
    "consistencygroup:get_all": "rule:admin_or_owner",

    "consistencygroup:create_cgsnapshot" : "rule:admin_or_owner",
    "consistencygroup:delete_cgsnapshot": "rule:admin_or_owner",
    "consistencygroup:get_cgsnapshot": "rule:admin_or_owner",
    "consistencygroup:get_all_cgsnapshots": "rule:admin_or_owner",

Once this is done, you will need to restart all 3 of the cinder
services in "screen -r" (c-api, c-sch, and c-vol).

The new panel is a tab named "Volume Consistency Groups" and is
located in "Project -> Volumes".

Co-Authored-By: Brad Pokorny <brad_pokorny@symantec.com>
Change-Id: I33ebe39e79d7c1d1dc7e741b4199bcb259b642d1
Partially-implements: blueprint cinder-consistency-groups
This commit is contained in:
Rich Hagarty 2015-12-09 09:04:45 -08:00
parent f071f9e3e7
commit 36c763168a
20 changed files with 1333 additions and 3 deletions

View File

@ -80,8 +80,9 @@ class Volume(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'size', 'status', 'created_at',
'volume_type', 'availability_zone', 'imageRef', 'bootable',
'snapshot_id', 'source_volid', 'attachments', 'tenant_name',
'os-vol-host-attr:host', 'os-vol-tenant-attr:tenant_id',
'metadata', 'volume_image_metadata', 'encrypted', 'transfer']
'consistencygroup_id', 'os-vol-host-attr:host',
'os-vol-tenant-attr:tenant_id', 'metadata',
'volume_image_metadata', 'encrypted', 'transfer']
@property
def is_bootable(self):
@ -102,6 +103,12 @@ class VolumeType(BaseCinderAPIResourceWrapper):
'os-extended-snapshot-attributes:project_id']
class VolumeConsistencyGroup(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'status', 'availability_zone',
'created_at', 'volume_types']
class VolumeBackup(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'container', 'size', 'status',
@ -420,6 +427,59 @@ def volume_snapshot_reset_state(request, snapshot_id, state):
snapshot_id, state)
def volume_cgroup_get(request, cgroup_id):
cgroup = cinderclient(request).consistencygroups.get(cgroup_id)
return VolumeConsistencyGroup(cgroup)
def volume_cgroup_list(request, search_opts=None):
c_client = cinderclient(request)
if c_client is None:
return []
return [VolumeConsistencyGroup(s) for s in c_client.consistencygroups.list(
search_opts=search_opts)]
def volume_cgroup_list_with_vol_type_names(request, search_opts=None):
cgroups = volume_cgroup_list(request, search_opts)
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)
cgroup.volume_type_names.append(vol_type.name)
return cgroups
def volume_cgroup_create(request, volume_types, name,
description=None, availability_zone=None):
return VolumeConsistencyGroup(
cinderclient(request).consistencygroups.create(
volume_types,
name,
description,
availability_zone=availability_zone))
def volume_cgroup_delete(request, cgroup_id, force=False):
return cinderclient(request).consistencygroups.delete(cgroup_id, force)
def volume_cgroup_update(request, cgroup_id, name=None, description=None,
add_vols=None, remove_vols=None):
cgroup_data = {}
if name:
cgroup_data['name'] = name
if description:
cgroup_data['description'] = description
if add_vols:
cgroup_data['add_volumes'] = add_vols
if remove_vols:
cgroup_data['remove_volumes'] = remove_vols
return cinderclient(request).consistencygroups.update(cgroup_id,
**cgroup_data)
@memoized
def volume_backup_supported(request):
"""This method will determine if cinder supports backup.

View File

@ -0,0 +1,47 @@
# 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 UpdateForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Name"))
description = forms.CharField(max_length=255,
widget=forms.Textarea(attrs={'rows': 4}),
label=_("Description"),
required=False)
def handle(self, request, data):
cgroup_id = self.initial['cgroup_id']
try:
cinder.volume_cgroup_update(request,
cgroup_id,
data['name'],
data['description'])
message = _('Updating volume consistency '
'group "%s"') % data['name']
messages.info(request, message)
return True
except Exception:
redirect = reverse("horizon:project:volumes:index")
exceptions.handle(request,
_('Unable to update volume consistency group.'),
redirect=redirect)

View File

@ -0,0 +1,149 @@
# 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 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
from openstack_dashboard.api import cinder
from openstack_dashboard import policy
class CreateVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "create"
verbose_name = _("Create Consistency Group")
url = "horizon:project:volumes:cgroups:create"
classes = ("ajax-modal",)
icon = "plus"
policy_rules = (("volume", "consistencygroup:create"),)
class DeleteVolumeCGroup(policy.PolicyTargetMixin, tables.DeleteAction):
name = "deletecg"
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 EditVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "edit"
verbose_name = _("Edit Consistency Group")
url = "horizon:project:volumes:cgroups:update"
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:update"),)
class ManageVolumes(policy.PolicyTargetMixin, tables.LinkAction):
name = "manage"
verbose_name = _("Manage Volumes")
url = "horizon:project:volumes:cgroups:manage"
classes = ("ajax-modal",)
policy_rules = (("volume", "consistencygroup:update"),)
class UpdateRow(tables.Row):
ajax = True
def get_data(self, request, cgroup_id):
cgroup = cinder.volume_cgroup_get(request, cgroup_id)
return cgroup
class VolumeCGroupsFilterAction(tables.FilterAction):
def filter(self, table, cgroups, filter_string):
"""Naive case-insensitive search."""
query = filter_string.lower()
return [cgroup for cgroup in cgroups
if query in cgroup.name.lower()]
def get_volume_types(cgroup):
vtypes_str = ",".join(cgroup.volume_type_names)
return vtypes_str
class VolumeCGroupsTable(tables.DataTable):
STATUS_CHOICES = (
("in-use", True),
("available", True),
("creating", None),
("error", False),
)
STATUS_DISPLAY_CHOICES = (
("available",
pgettext_lazy("Current status of Consistency Group", u"Available")),
("in-use",
pgettext_lazy("Current status of Consistency Group", u"In-use")),
("error",
pgettext_lazy("Current status of Consistency Group", u"Error")),
)
name = tables.Column("name",
verbose_name=_("Name"),
link="horizon:project:volumes:cgroups: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)
availability_zone = tables.Column("availability_zone",
verbose_name=_("Availability Zone"))
volume_type = tables.Column(get_volume_types,
verbose_name=_("Volume Type(s)"))
def get_object_id(self, cgroup):
return cgroup.id
class Meta(object):
name = "volume_cgroups"
verbose_name = _("Volume Consistency Groups")
table_actions = (CreateVolumeCGroup,
VolumeCGroupsFilterAction)
row_actions = (ManageVolumes,
EditVolumeCGroup,
DeleteVolumeCGroup)
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/cgroups/_detail_overview.html")
def get_context_data(self, request):
cgroup = self.tab_group.kwargs['cgroup']
return {"cgroup": cgroup}
def get_redirect_url(self):
return reverse('horizon:project:volumes:index')
class CGroupsDetailTabs(tabs.TabGroup):
slug = "cgroup_details"
tabs = (OverviewTab,)

View File

@ -0,0 +1,234 @@
# 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 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_CGROUPS_TAB_URL = reverse('horizon:project:volumes:cgroups_tab')
class ConsistencyGroupTests(test.TestCase):
@test.create_stubs({cinder: ('volume_cgroup_create',
'volume_cgroup_list',
'volume_type_list',
'volume_type_list_with_qos_associations',
'availability_zone_list',
'extension_supported')})
def test_create_cgroup(self):
cgroup = self.cinder_consistencygroups.first()
volume_types = self.volume_types.list()
az = self.cinder_availability_zones.first().zoneName
formData = {'volume_types': '1',
'name': 'test CG',
'description': 'test desc',
'availability_zone': az}
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)).\
AndReturn(self.cinder_consistencygroups.list())
cinder.volume_cgroup_create(
IsA(http.HttpRequest),
formData['volume_types'],
formData['name'],
formData['description'],
availability_zone=formData['availability_zone'])\
.AndReturn(cgroup)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:create')
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_cgroup_create',
'volume_cgroup_list',
'volume_type_list',
'volume_type_list_with_qos_associations',
'availability_zone_list',
'extension_supported')})
def test_create_cgroup_exception(self):
volume_types = self.volume_types.list()
az = self.cinder_availability_zones.first().zoneName
formData = {'volume_types': '1',
'name': 'test CG',
'description': 'test desc',
'availability_zone': az}
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)).\
AndReturn(self.cinder_consistencygroups.list())
cinder.volume_cgroup_create(
IsA(http.HttpRequest),
formData['volume_types'],
formData['name'],
formData['description'],
availability_zone=formData['availability_zone'])\
.AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:create')
res = self.client.post(url, formData)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_list_with_vol_type_names',
'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_delete(IsA(http.HttpRequest), cgroup.id,
force=False)
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']])
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
def test_update_cgroup_add_vol(self):
cgroup = self.cinder_consistencygroups.first()
volume = self.cinder_volumes.first()
formData = {'volume_types': '1',
'name': 'test CG',
'description': 'test desc'}
cinder.volume_cgroup_get(IsA(
http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_update(
IsA(http.HttpRequest),
formData['name'],
formData['description'],
add_vols=volume)\
.AndReturn(cgroup)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:update',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
def test_update_cgroup_remove_vol(self):
cgroup = self.cinder_consistencygroups.first()
volume = self.cinder_volumes.first()
formData = {'volume_types': '1',
'name': 'test CG',
'description': 'test desc'}
cinder.volume_cgroup_get(IsA(
http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_update(
IsA(http.HttpRequest),
formData['name'],
formData['description'],
remove_vols=volume)\
.AndReturn(cgroup)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:update',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
def test_update_cgroup_name_and_description(self):
cgroup = self.cinder_consistencygroups.first()
formData = {'volume_types': '1',
'name': 'test CG-new',
'description': 'test desc-new'}
cinder.volume_cgroup_get(IsA(
http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_update(
IsA(http.HttpRequest),
formData['name'],
formData['description'])\
.AndReturn(cgroup)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:update',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
@test.create_stubs({cinder: ('volume_cgroup_update',
'volume_cgroup_get')})
def test_update_cgroup_with_exception(self):
cgroup = self.cinder_consistencygroups.first()
formData = {'volume_types': '1',
'name': 'test CG-new',
'description': 'test desc-new'}
cinder.volume_cgroup_get(IsA(
http.HttpRequest), cgroup.id).\
AndReturn(cgroup)
cinder.volume_cgroup_update(
IsA(http.HttpRequest),
formData['name'],
formData['description'])\
.AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:update',
args=[cgroup.id])
res = self.client.post(url, formData)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)
@test.create_stubs({cinder: ('volume_cgroup_get',)})
def test_detail_view_with_exception(self):
cgroup = self.cinder_consistencygroups.first()
cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\
AndRaise(self.exceptions.cinder)
self.mox.ReplayAll()
url = reverse('horizon:project:volumes:cgroups:detail',
args=[cgroup.id])
res = self.client.get(url)
self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL)

View File

@ -0,0 +1,33 @@
# 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.cgroups import views
urlpatterns = patterns(
'',
url(r'^create/$',
views.CreateView.as_view(),
name='create'),
url(r'^(?P<cgroup_id>[^/]+)/update/$',
views.UpdateView.as_view(),
name='update'),
url(r'^(?P<cgroup_id>[^/]+)/manage/$',
views.ManageView.as_view(),
name='manage'),
url(r'^(?P<cgroup_id>[^/]+)$',
views.DetailView.as_view(),
name='detail'),
)

View File

@ -0,0 +1,155 @@
# 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 horizon import workflows
from openstack_dashboard import api
from openstack_dashboard.api import cinder
from openstack_dashboard.dashboards.project.volumes \
.cgroups import workflows as vol_cgroup_workflows
from openstack_dashboard.dashboards.project.volumes \
.cgroups import forms as vol_cgroup_forms
from openstack_dashboard.dashboards.project.volumes \
.cgroups import tables as vol_cgroup_tables
from openstack_dashboard.dashboards.project.volumes \
.cgroups import tabs as vol_cgroup_tabs
CGROUP_INFO_FIELDS = ("name",
"description")
INDEX_URL = "horizon:project:volumes:index"
class CreateView(workflows.WorkflowView):
workflow_class = vol_cgroup_workflows.CreateCGroupWorkflow
template_name = 'project/volumes/cgroups/create.html'
page_title = _("Create Volume Consistency Group")
class UpdateView(forms.ModalFormView):
template_name = 'project/volumes/cgroups/update.html'
modal_header = _("Edit Consistency Group")
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
page_title = modal_header
def get_initial(self):
cgroup = self.get_object()
return {'cgroup_id': self.kwargs["cgroup_id"],
'name': cgroup.name,
'description': cgroup.description}
def get_context_data(self, **kwargs):
context = super(UpdateView, 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
def get_context_data(self, **kwargs):
context = super(ManageView, self).get_context_data(**kwargs)
context['cgroup_id'] = self.kwargs["cgroup_id"]
return context
def _get_object(self, *args, **kwargs):
cgroup_id = self.kwargs['cgroup_id']
try:
cgroup = 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 cgroup
def get_initial(self):
cgroup = self._get_object()
return {'cgroup_id': cgroup.id,
'name': cgroup.name,
'description': cgroup.description,
'vtypes': getattr(cgroup, "volume_types")}
class DetailView(tabs.TabView):
tab_group_class = vol_cgroup_tabs.CGroupsDetailTabs
template_name = 'horizon/common/_detail.html'
page_title = "{{ cgroup.name|default:cgroup.id }}"
def get_context_data(self, **kwargs):
context = super(DetailView, self).get_context_data(**kwargs)
cgroup = self.get_data()
table = vol_cgroup_tables.VolumeCGroupsTable(self.request)
context["cgroup"] = cgroup
context["url"] = self.get_redirect_url()
context["actions"] = table.render_row_actions(cgroup)
return context
@memoized.memoized_method
def get_data(self):
try:
cgroup_id = self.kwargs['cgroup_id']
cgroup = api.cinder.volume_cgroup_get(self.request,
cgroup_id)
cgroup.volume_type_names = []
for vol_type_id in cgroup.volume_types:
vol_type = api.cinder.volume_type_get(self.request,
vol_type_id)
cgroup.volume_type_names.append(vol_type.name)
cgroup.volume_names = []
search_opts = {'consistencygroup_id': cgroup_id}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
for volume in volumes:
cgroup.volume_names.append(volume.name)
except Exception:
redirect = self.get_redirect_url()
exceptions.handle(self.request,
_('Unable to retrieve consistency group '
'details.'),
redirect=redirect)
return cgroup
@staticmethod
def get_redirect_url():
return reverse('horizon:project:volumes:index')
def get_tabs(self, request, *args, **kwargs):
cgroup = self.get_data()
return self.tab_group_class(request, cgroup=cgroup, **kwargs)

View File

@ -0,0 +1,423 @@
# 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 ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import messages
from horizon import workflows
from openstack_dashboard import api
from openstack_dashboard.api import cinder
INDEX_URL = "horizon:project:volumes:index"
CGROUP_VOLUME_MEMBER_SLUG = "update_members"
def cinder_az_supported(request):
try:
return cinder.extension_supported(request, 'AvailabilityZones')
except Exception:
exceptions.handle(request, _('Unable to determine if availability '
'zones extension is supported.'))
return False
def availability_zones(request):
zone_list = []
if cinder_az_supported(request):
try:
zones = api.cinder.availability_zone_list(request)
zone_list = [(zone.zoneName, zone.zoneName)
for zone in zones if zone.zoneState['available']]
zone_list.sort()
except Exception:
exceptions.handle(request, _('Unable to retrieve availability '
'zones.'))
if not zone_list:
zone_list.insert(0, ("", _("No availability zones found")))
elif len(zone_list) > 1:
zone_list.insert(0, ("", _("Any Availability Zone")))
return zone_list
class AddCGroupInfoAction(workflows.Action):
name = forms.CharField(label=_("Name"),
max_length=255)
description = forms.CharField(widget=forms.widgets.Textarea(
attrs={'rows': 4}),
label=_("Description"),
required=False)
availability_zone = forms.ChoiceField(
label=_("Availability Zone"),
required=False,
widget=forms.Select(
attrs={'class': 'switched',
'data-switch-on': 'source',
'data-source-no_source_type': _('Availability Zone'),
'data-source-image_source': _('Availability Zone')}))
def __init__(self, request, *args, **kwargs):
super(AddCGroupInfoAction, self).__init__(request,
*args,
**kwargs)
self.fields['availability_zone'].choices = \
availability_zones(request)
class Meta(object):
name = _("Consistency Group Information")
help_text = _("Volume consistency groups provide a mechanism for "
"creating snapshots of multiple volumes at the same "
"point-in-time to ensure data consistency\n\n"
"A consistency group can support more than one volume "
"type, but it can only contain volumes hosted by the "
"same back end.")
slug = "set_cgroup_info"
def clean(self):
cleaned_data = super(AddCGroupInfoAction, self).clean()
name = cleaned_data.get('name')
try:
cgroups = cinder.volume_cgroup_list(self.request)
except Exception:
msg = _('Unable to get consistency group list')
exceptions.check_message(["Connection", "refused"], msg)
raise
if cgroups is not None and name is not None:
for cgroup in cgroups:
if cgroup.name.lower() == name.lower():
raise forms.ValidationError(
_('The name "%s" is already used by '
'another consistency group.')
% name
)
return cleaned_data
class AddCGroupInfoStep(workflows.Step):
action_class = AddCGroupInfoAction
contributes = ("availability_zone",
"description",
"name")
class AddVolumeTypesToCGroupAction(workflows.MembershipAction):
def __init__(self, request, *args, **kwargs):
super(AddVolumeTypesToCGroupAction, self).__init__(request,
*args,
**kwargs)
err_msg = _('Unable to get the available volume types')
default_role_field_name = self.get_default_role_field_name()
self.fields[default_role_field_name] = forms.CharField(required=False)
self.fields[default_role_field_name].initial = 'member'
field_name = self.get_member_field_name('member')
self.fields[field_name] = forms.MultipleChoiceField(required=False)
vtypes = []
try:
vtypes = cinder.volume_type_list(request)
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]
class Meta(object):
name = _("Manage Volume Types")
slug = "add_vtypes_to_cgroup"
class AddVolTypesToCGroupStep(workflows.UpdateMembersStep):
action_class = AddVolumeTypesToCGroupAction
help_text = _("Add volume types to this consistency group. "
"Multiple volume types can be added to the same "
"consistency group only if they are associated with "
"same back end.")
available_list_title = _("All available volume types")
members_list_title = _("Selected volume types")
no_available_text = _("No volume types found.")
no_members_text = _("No volume types selected.")
show_roles = False
contributes = ("volume_types",)
def contribute(self, data, context):
if data:
member_field_name = self.get_member_field_name('member')
context['volume_types'] = data.get(member_field_name, [])
return context
class AddVolumesToCGroupAction(workflows.MembershipAction):
def __init__(self, request, *args, **kwargs):
super(AddVolumesToCGroupAction, self).__init__(request,
*args,
**kwargs)
err_msg = _('Unable to get the available volumes')
default_role_field_name = self.get_default_role_field_name()
self.fields[default_role_field_name] = forms.CharField(required=False)
self.fields[default_role_field_name].initial = 'member'
field_name = self.get_member_field_name('member')
self.fields[field_name] = forms.MultipleChoiceField(required=False)
vtypes = self.initial['vtypes']
try:
# get names of volume types associated with CG
vtype_names = []
volume_types = cinder.volume_type_list(request)
for volume_type in volume_types:
if volume_type.id in vtypes:
vtype_names.append(volume_type.name)
# collect volumes that are associated with volume types
vol_list = []
volumes = cinder.volume_list(request)
for volume in volumes:
if volume.volume_type in vtype_names:
in_this_cgroup = False
if hasattr(volume, 'consistencygroup_id'):
if volume.consistencygroup_id == \
self.initial['cgroup_id']:
in_this_cgroup = True
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
for index, volume in enumerate(sorted_vol_list):
if index < len(sorted_vol_list) - 1:
if volume['volume_name'] == \
sorted_vol_list[index + 1]['volume_name']:
volume['is_duplicate'] = True
sorted_vol_list[index + 1]['is_duplicate'] = True
# update display with all available vols and those already
# assigned to consistency group
available_vols = []
assigned_vols = []
for volume in sorted_vol_list:
if volume['is_duplicate']:
# add id to differentiate volumes to user
entry = volume['volume_name'] + \
" [" + volume['volume_id'] + "]"
else:
entry = volume['volume_name']
available_vols.append((entry, entry))
if volume['in_cgroup']:
assigned_vols.append(entry)
except Exception:
exceptions.handle(request, err_msg)
self.fields[field_name].choices = \
available_vols
self.fields[field_name].initial = assigned_vols
class Meta(object):
name = _("Manage Volumes")
slug = "add_volumes_to_cgroup"
class AddVolumesToCGroupStep(workflows.UpdateMembersStep):
action_class = AddVolumesToCGroupAction
help_text = _("Add/remove volumes to/from this consistency group. "
"Only volumes associated with the volume type(s) assigned "
"to this consistency group will be available for selection.")
available_list_title = _("All available volumes")
members_list_title = _("Selected volumes")
no_available_text = _("No volumes found.")
no_members_text = _("No volumes selected.")
show_roles = False
depends_on = ("cgroup_id", "name", "vtypes")
contributes = ("volumes",)
def contribute(self, data, context):
if data:
member_field_name = self.get_member_field_name('member')
context['volumes'] = data.get(member_field_name, [])
return context
class CreateCGroupWorkflow(workflows.Workflow):
slug = "create_cgroup"
name = _("Create Consistency Group")
finalize_button_name = _("Create Consistency Group")
failure_message = _('Unable to create consistency group.')
success_message = _('Created new volume consistency group')
success_url = INDEX_URL
default_steps = (AddCGroupInfoStep,
AddVolTypesToCGroupStep)
def handle(self, request, context):
selected_vol_types = context['volume_types']
try:
vol_types = cinder.volume_type_list_with_qos_associations(
request)
except Exception:
msg = _('Unable to get volume type list')
exceptions.check_message(["Connection", "refused"], msg)
return False
# ensure that all selected volume types share same backend name
backend_name = None
invalid_backend = False
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 hasattr(vol_type, "extra_specs"):
vol_type_backend = \
vol_type.extra_specs['volume_backend_name']
if vol_type_backend is None:
invalid_backend = True
break
if backend_name is None:
backend_name = vol_type_backend
if vol_type_backend != backend_name:
invalid_backend = True
break
else:
invalid_backend = True
break
if invalid_backend:
msg = _('All selected volume types must be associated '
'with the same volume backend name.')
exceptions.handle(request, msg)
return False
try:
vtypes_str = ",".join(context['volume_types'])
self.object = \
cinder.volume_cgroup_create(
request,
vtypes_str,
name=context['name'],
description=context['description'],
availability_zone=context['availability_zone'])
except Exception:
exceptions.handle(request, _('Unable to create consistency '
'group.'))
return False
return True
class UpdateCGroupWorkflow(workflows.Workflow):
slug = "create_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')
success_url = INDEX_URL
default_steps = (AddVolumesToCGroupStep,)
def handle(self, request, context):
cgroup_id = context['cgroup_id']
add_vols = []
remove_vols = []
try:
selected_volumes = context['volumes']
volumes = cinder.volume_list(request)
# scan all volumes and make correct consistency group is set
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:
selected = True
else:
selected = True
if selected:
break
if selected:
# ensure this volume is in this consistency group
if hasattr(volume, 'consistencygroup_id'):
if volume.consistencygroup_id != cgroup_id:
add_vols.append(volume.id)
else:
add_vols.append(volume.id)
else:
# ensure this volume is not in our consistency group
if hasattr(volume, 'consistencygroup_id'):
if volume.consistencygroup_id == cgroup_id:
remove_vols.append(volume.id)
add_vols_str = ",".join(add_vols)
remove_vols_str = ",".join(remove_vols)
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.'))
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

@ -20,9 +20,12 @@ from horizon import exceptions
from horizon import tabs
from openstack_dashboard import api
from openstack_dashboard import policy
from openstack_dashboard.dashboards.project.volumes.backups \
import tables as backups_tables
from openstack_dashboard.dashboards.project.volumes.cgroups \
import tables as vol_cgroup_tables
from openstack_dashboard.dashboards.project.volumes.snapshots \
import tables as vol_snapshot_tables
from openstack_dashboard.dashboards.project.volumes.volumes \
@ -203,7 +206,33 @@ class BackupsTab(PagedTableMixin, tabs.TableTab, VolumeTableMixIn):
return backups
class CGroupsTab(tabs.TableTab, VolumeTableMixIn):
table_classes = (vol_cgroup_tables.VolumeCGroupsTable,)
name = _("Volume Consistency Groups")
slug = "cgroups_tab"
template_name = ("horizon/common/_detail_table.html")
preload = False
def allowed(self, request):
return policy.check(
(("volume", "consistencygroup:get_all"),),
request
)
def get_volume_cgroups_data(self):
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 "
"volume consistency groups."))
return cgroups
class VolumeAndSnapshotTabs(tabs.TabGroup):
slug = "volumes_and_snapshots"
tabs = (VolumeTab, SnapshotTab, BackupsTab)
tabs = (VolumeTab, SnapshotTab, BackupsTab, CGroupsTab)
sticky = True

View File

@ -0,0 +1,10 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% 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 "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,34 @@
{% load i18n sizeformat parse_date %}
<div class="detail">
<dl class="dl-horizontal">
<dt>{% trans "Name" %}</dt>
<dd>{{ cgroup.name }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ cgroup.id }}</dd>
<dt>{% trans "Description" %}</dt>
<dd>{{ cgroup.description }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>{{ cgroup.status|capfirst }}</dd>
</dl>
<h4>{% trans "Volume Types" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_type_names in cgroup.volume_type_names %}
<dd>{{ vol_type_names }}</dd>
{% endfor %}
</dl>
<h4>{% trans "Volumes" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_names in cgroup.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." %}</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'horizon/common/_workflow.html' %}
{% endblock %}

View File

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

View File

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

View File

@ -18,6 +18,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.cgroups \
import urls as cgroup_urls
from openstack_dashboard.dashboards.project.volumes.snapshots \
import urls as snapshot_urls
from openstack_dashboard.dashboards.project.volumes import views
@ -33,7 +35,10 @@ urlpatterns = patterns(
views.IndexView.as_view(), name='volumes_tab'),
url(r'^\?tab=volumes_and_snapshots__backups_tab$',
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')),
)

View File

@ -333,6 +333,46 @@ class CinderApiTests(test.APITestCase):
default_volume_type = api.cinder.volume_type_default(self.request)
self.assertEqual(default_volume_type, volume_type)
def test_cgroup_list(self):
cgroups = self.cinder_consistencygroups.list()
cinderclient = self.stub_cinderclient()
cinderclient.consistencygroups = self.mox.CreateMockAnything()
cinderclient.consistencygroups.list(search_opts=None).\
AndReturn(cgroups)
self.mox.ReplayAll()
api_cgroups = api.cinder.volume_cgroup_list(self.request)
self.assertEqual(len(cgroups), len(api_cgroups))
def test_cgroup_get(self):
cgroup = self.cinder_consistencygroups.first()
cinderclient = self.stub_cinderclient()
cinderclient.consistencygroups = self.mox.CreateMockAnything()
cinderclient.consistencygroups.get(cgroup.id).AndReturn(cgroup)
self.mox.ReplayAll()
api_cgroup = api.cinder.volume_cgroup_get(self.request, cgroup.id)
self.assertEqual(api_cgroup.name, cgroup.name)
self.assertEqual(api_cgroup.description, cgroup.description)
self.assertEqual(api_cgroup.volume_types, cgroup.volume_types)
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)
self.mox.ReplayAll()
api_cgroups = api.cinder.volume_cgroup_list_with_vol_type_names(
self.request)
self.assertEqual(len(cgroups), len(api_cgroups))
for i in range(len(api_cgroups[0].volume_type_names)):
self.assertEqual(volume_types_list[i].name,
api_cgroups[0].volume_type_names[i])
class CinderApiVersionTests(test.TestCase):

View File

@ -13,6 +13,7 @@
# under the License.
from cinderclient.v2 import availability_zones
from cinderclient.v2 import consistencygroups
from cinderclient.v2 import pools
from cinderclient.v2 import qos_specs
from cinderclient.v2 import quotas
@ -46,6 +47,8 @@ def data(TEST):
TEST.cinder_availability_zones = utils.TestDataContainer()
TEST.cinder_volume_transfers = utils.TestDataContainer()
TEST.cinder_pools = utils.TestDataContainer()
TEST.cinder_consistencygroups = utils.TestDataContainer()
TEST.cinder_cgroup_volumes = utils.TestDataContainer()
# Services
service_1 = services.Service(services.ServiceManager(None), {
@ -382,3 +385,37 @@ def data(TEST):
TEST.cinder_pools.add(pool1)
TEST.cinder_pools.add(pool2)
# volume consistency groups
cgroup_1 = consistencygroups.Consistencygroup(
consistencygroups.ConsistencygroupManager(None),
{'id': u'1',
'name': u'cg_1',
'description': 'cg 1 description',
'volume_types': u'1',
'volume_type_names': []})
cgroup_2 = consistencygroups.Consistencygroup(
consistencygroups.ConsistencygroupManager(None),
{'id': u'2',
'name': u'cg_2',
'description': 'cg 2 description',
'volume_types': u'1',
'volume_type_names': []})
TEST.cinder_consistencygroups.add(cgroup_1)
TEST.cinder_consistencygroups.add(cgroup_2)
volume_for_consistency_group = volumes.Volume(
volumes.VolumeManager(None),
{'id': "11023e92-8008-4c8b-8059-7f2293ff3881",
'status': 'available',
'size': 40,
'display_name': 'Volume name',
'display_description': 'Volume description',
'created_at': '2014-01-27 10:30:00',
'volume_type': None,
'attachments': [],
'consistencygroup_id': u'1'})
TEST.cinder_cgroup_volumes.add(api.cinder.Volume(
volume_for_consistency_group))

View File

@ -0,0 +1,12 @@
---
features:
- >
[`blueprint cinder-consistency-groups <https://blueprints.launchpad.net/horizon/+spec/cinder-consistency-groups>`_]
This feature adds a new Consistency Groups tab to the Project Volumes panel.
Consistency Groups (GG) contain existing volumes, and allow the user to perform
actions on the volumes in one step. Actions include: create a CG, manage volumes
associated with the CG, update a CG, and delete a CGs. Note that a CG can not be
deleted if it contains any volumes.
security:
- Policies associated with Consistency Groups exist in the Cinder policy file, and
by default, all actions are disabled.