diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 96f16bacf..9310941b4 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -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. diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/__init__.py b/openstack_dashboard/dashboards/project/volumes/cgroups/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py b/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py new file mode 100644 index 000000000..d857281b8 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py @@ -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) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py b/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py new file mode 100644 index 000000000..f02402b56 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py @@ -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'] diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/tabs.py b/openstack_dashboard/dashboards/project/volumes/cgroups/tabs.py new file mode 100644 index 000000000..de0d8cf3d --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/tabs.py @@ -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,) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py b/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py new file mode 100644 index 000000000..b0eacb340 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py @@ -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) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py b/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py new file mode 100644 index 000000000..9d27fc75e --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py @@ -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[^/]+)/update/$', + views.UpdateView.as_view(), + name='update'), + url(r'^(?P[^/]+)/manage/$', + views.ManageView.as_view(), + name='manage'), + url(r'^(?P[^/]+)$', + views.DetailView.as_view(), + name='detail'), +) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/views.py b/openstack_dashboard/dashboards/project/volumes/cgroups/views.py new file mode 100644 index 000000000..c20665dc4 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/views.py @@ -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) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py b/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py new file mode 100644 index 000000000..841702d82 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py @@ -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 diff --git a/openstack_dashboard/dashboards/project/volumes/tabs.py b/openstack_dashboard/dashboards/project/volumes/tabs.py index 8d36d307f..3b49f9d63 100644 --- a/openstack_dashboard/dashboards/project/volumes/tabs.py +++ b/openstack_dashboard/dashboards/project/volumes/tabs.py @@ -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 diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_delete.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_delete.html new file mode 100644 index 000000000..94c18d42b --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_delete.html @@ -0,0 +1,10 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block modal-body-right %} +

{% trans "Volume consistency groups can only be deleted after all the volumes they contain are either deleted or unassigned." %}

+

{% trans "The default action for deleting a consistency group is to first disassociate all associated volumes." %}

+

{% trans "Check the "Delete Volumes" box to also delete the volumes associated with this consistency group." %}

+

{% trans "Note that a volume can not be deleted if it is "attached" or has any dependent snapshots." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_detail_overview.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_detail_overview.html new file mode 100644 index 000000000..9c2e5601d --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_detail_overview.html @@ -0,0 +1,34 @@ +{% load i18n sizeformat parse_date %} + +
+
+
{% trans "Name" %}
+
{{ cgroup.name }}
+
{% trans "ID" %}
+
{{ cgroup.id }}
+
{% trans "Description" %}
+
{{ cgroup.description }}
+
{% trans "Status" %}
+
{{ cgroup.status|capfirst }}
+
+ +

{% trans "Volume Types" %}

+
+
+ {% for vol_type_names in cgroup.volume_type_names %} +
{{ vol_type_names }}
+ {% endfor %} +
+ +

{% trans "Volumes" %}

+
+
+ {% for vol_names in cgroup.volume_names %} +
{{ vol_names }}
+ {% empty %} +
+ {% trans "No assigned volumes" %} +
+ {% endfor %} +
+
diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_update.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_update.html new file mode 100644 index 000000000..82d3e5b3b --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_update.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block modal-body-right %} +

{% trans "Modify the name and description of a volume consistency group." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/create.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/create.html new file mode 100644 index 000000000..ec93bb561 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/delete.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/delete.html new file mode 100644 index 000000000..254fecc3b --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/delete.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cgroups/_delete.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/update.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/update.html new file mode 100644 index 000000000..2dc627115 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cgroups/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/urls.py index 3fe76dce8..22287c5cb 100644 --- a/openstack_dashboard/dashboards/project/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/urls.py @@ -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')), ) diff --git a/openstack_dashboard/test/api_tests/cinder_tests.py b/openstack_dashboard/test/api_tests/cinder_tests.py index 80736404b..25f50d562 100644 --- a/openstack_dashboard/test/api_tests/cinder_tests.py +++ b/openstack_dashboard/test/api_tests/cinder_tests.py @@ -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): diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index e8b41dd54..7e8cdd469 100644 --- a/openstack_dashboard/test/test_data/cinder_data.py +++ b/openstack_dashboard/test/test_data/cinder_data.py @@ -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)) diff --git a/releasenotes/notes/bp-cinder-consistency-groups-b0aba555b1ed4a6c.yaml b/releasenotes/notes/bp-cinder-consistency-groups-b0aba555b1ed4a6c.yaml new file mode 100644 index 000000000..2ee0f30b3 --- /dev/null +++ b/releasenotes/notes/bp-cinder-consistency-groups-b0aba555b1ed4a6c.yaml @@ -0,0 +1,12 @@ +--- +features: + - > + [`blueprint 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.