diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 50af8137a..d8d217106 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -109,6 +109,12 @@ class VolumeConsistencyGroup(BaseCinderAPIResourceWrapper): 'created_at', 'volume_types'] +class VolumeCGSnapshot(BaseCinderAPIResourceWrapper): + + _attrs = ['id', 'name', 'description', 'status', + 'created_at', 'consistencygroup_id'] + + class VolumeBackup(BaseCinderAPIResourceWrapper): _attrs = ['id', 'name', 'description', 'container', 'size', 'status', @@ -432,6 +438,18 @@ def volume_cgroup_get(request, cgroup_id): return VolumeConsistencyGroup(cgroup) +def volume_cgroup_get_with_vol_type_names(request, cgroup_id): + cgroup = volume_cgroup_get(request, cgroup_id) + vol_types = volume_type_list(request) + cgroup.volume_type_names = [] + for vol_type_id in cgroup.volume_types: + for vol_type in vol_types: + if vol_type.id == vol_type_id: + cgroup.volume_type_names.append(vol_type.name) + break + return cgroup + + def volume_cgroup_list(request, search_opts=None): c_client = cinderclient(request) if c_client is None: @@ -442,23 +460,41 @@ def volume_cgroup_list(request, search_opts=None): def volume_cgroup_list_with_vol_type_names(request, search_opts=None): cgroups = volume_cgroup_list(request, search_opts) + vol_types = volume_type_list(request) for cgroup in cgroups: cgroup.volume_type_names = [] for vol_type_id in cgroup.volume_types: - vol_type = volume_type_get(request, vol_type_id) - cgroup.volume_type_names.append(vol_type.name) + for vol_type in vol_types: + if vol_type.id == vol_type_id: + cgroup.volume_type_names.append(vol_type.name) + break return cgroups def volume_cgroup_create(request, volume_types, name, description=None, availability_zone=None): + data = {'name': name, + 'description': description, + 'availability_zone': availability_zone} + + cgroup = cinderclient(request).consistencygroups.create(volume_types, + **data) + return VolumeConsistencyGroup(cgroup) + + +def volume_cgroup_create_from_source(request, name, cg_snapshot_id=None, + source_cgroup_id=None, + description=None, + user_id=None, project_id=None): return VolumeConsistencyGroup( - cinderclient(request).consistencygroups.create( - volume_types, + cinderclient(request).consistencygroups.create_from_src( + cg_snapshot_id, + source_cgroup_id, name, description, - availability_zone=availability_zone)) + user_id, + project_id)) def volume_cgroup_delete(request, cgroup_id, force=False): @@ -480,6 +516,32 @@ def volume_cgroup_update(request, cgroup_id, name=None, description=None, **cgroup_data) +def volume_cg_snapshot_create(request, cgroup_id, name, + description=None): + return VolumeCGSnapshot( + cinderclient(request).cgsnapshots.create( + cgroup_id, + name, + description)) + + +def volume_cg_snapshot_get(request, cg_snapshot_id): + cgsnapshot = cinderclient(request).cgsnapshots.get(cg_snapshot_id) + return VolumeCGSnapshot(cgsnapshot) + + +def volume_cg_snapshot_list(request, search_opts=None): + c_client = cinderclient(request) + if c_client is None: + return [] + return [VolumeCGSnapshot(s) for s in c_client.cgsnapshots.list( + search_opts=search_opts)] + + +def volume_cg_snapshot_delete(request, cg_snapshot_id): + return cinderclient(request).cgsnapshots.delete(cg_snapshot_id) + + @memoized def volume_backup_supported(request): """This method will determine if cinder supports backup. diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/__init__.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/forms.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/forms.py new file mode 100644 index 000000000..c5810f8ee --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/forms.py @@ -0,0 +1,76 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard.api import cinder + + +class CreateCGroupForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Consistency Group Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + snapshot_source = forms.ChoiceField( + label=_("Use snapshot as a source"), + widget=forms.SelectWidget( + attrs={'class': 'snapshot-selector'}, + data_attrs=('name'), + transform=lambda x: "%s" % (x.name)), + required=False) + + def prepare_snapshot_source_field(self, request, cg_snapshot_id): + try: + cg_snapshot = cinder.volume_cg_snapshot_get(request, + cg_snapshot_id) + self.fields['snapshot_source'].choices = ((cg_snapshot_id, + cg_snapshot),) + except Exception: + exceptions.handle(request, + _('Unable to load the specified snapshot.')) + + def __init__(self, request, *args, **kwargs): + super(CreateCGroupForm, self).__init__(request, *args, **kwargs) + + # populate cgroup_id + cg_snapshot_id = kwargs.get('initial', {}).get('cg_snapshot_id', []) + self.fields['cg_snapshot_id'] = forms.CharField( + widget=forms.HiddenInput(), + initial=cg_snapshot_id) + self.prepare_snapshot_source_field(request, cg_snapshot_id) + + def handle(self, request, data): + try: + + message = _('Creating consistency group "%s".') % data['name'] + cgroup = cinder.volume_cgroup_create_from_source( + request, + data['name'], + cg_snapshot_id=data['cg_snapshot_id'], + description=data['description']) + + messages.info(request, message) + return cgroup + except Exception: + redirect = reverse("horizon:project:volumes:index") + msg = _('Unable to create consistency ' + 'group "%s" from snapshot.') % data['name'] + exceptions.handle(request, + msg, + redirect=redirect) diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tables.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tables.py new file mode 100644 index 000000000..45ee8fdd2 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tables.py @@ -0,0 +1,116 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from horizon import tables + +from openstack_dashboard.api import cinder +from openstack_dashboard import policy + + +class CreateVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "create_cgroup" + verbose_name = _("Create Consistency Group") + url = "horizon:project:volumes:cg_snapshots:create_cgroup" + classes = ("ajax-modal",) + policy_rules = (("volume", "consistencygroup:create"),) + + +class DeleteVolumeCGSnapshot(policy.PolicyTargetMixin, tables.DeleteAction): + name = "delete_cg_snapshot" + policy_rules = (("volume", "consistencygroup:delete_cgsnapshot"),) + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete Snapshot", + u"Delete Snapshots", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Scheduled deletion of Snapshot", + u"Scheduled deletion of Snapshots", + count + ) + + def delete(self, request, obj_id): + cinder.volume_cg_snapshot_delete(request, obj_id) + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, cg_snapshot_id): + cg_snapshot = cinder.volume_cg_snapshot_get(request, cg_snapshot_id) + return cg_snapshot + + +class VolumeCGSnapshotsFilterAction(tables.FilterAction): + + def filter(self, table, cg_snapshots, filter_string): + """Naive case-insensitive search.""" + query = filter_string.lower() + return [cg_snapshot for cg_snapshot in cg_snapshots + if query in cg_snapshot.name.lower()] + + +class CGSnapshotsTable(tables.DataTable): + STATUS_CHOICES = ( + ("in-use", True), + ("available", True), + ("creating", None), + ("error", False), + ) + STATUS_DISPLAY_CHOICES = ( + ("available", + pgettext_lazy("Current status of Consistency Group Snapshot", + u"Available")), + ("in-use", + pgettext_lazy("Current status of Consistency Group Snapshot", + u"In-use")), + ("error", + pgettext_lazy("Current status of Consistency Group Snapshot", + u"Error")), + ) + + name = tables.Column("name", + verbose_name=_("Name"), + link="horizon:project:volumes:" + "cg_snapshots:cg_snapshot_detail") + description = tables.Column("description", + verbose_name=_("Description"), + truncate=40) + status = tables.Column("status", + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES, + display_choices=STATUS_DISPLAY_CHOICES) + + def get_object_id(self, cg_snapshot): + return cg_snapshot.id + + class Meta(object): + name = "volume_cg_snapshots" + verbose_name = _("Consistency Group Snapshots") + table_actions = (VolumeCGSnapshotsFilterAction, + DeleteVolumeCGSnapshot) + row_actions = (CreateVolumeCGroup, + DeleteVolumeCGSnapshot,) + row_class = UpdateRow + status_columns = ("status",) + permissions = ['openstack.services.volume'] diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tabs.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tabs.py new file mode 100644 index 000000000..b0ba1cb05 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/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/cg_snapshots/_detail_overview.html") + + def get_context_data(self, request): + cg_snapshot = self.tab_group.kwargs['cg_snapshot'] + return {"cg_snapshot": cg_snapshot} + + def get_redirect_url(self): + return reverse('horizon:project:volumes:cg_snapshots:index') + + +class CGSnapshotsDetailTabs(tabs.TabGroup): + slug = "cg_snapshots_details" + tabs = (OverviewTab,) diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tests.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tests.py new file mode 100644 index 000000000..858b526cb --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/tests.py @@ -0,0 +1,166 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.core.urlresolvers import reverse +from django import http +from django.utils.http import urlunquote +from mox3.mox import IsA # noqa + +from openstack_dashboard.api import cinder +from openstack_dashboard.test import helpers as test + + +VOLUME_INDEX_URL = reverse('horizon:project:volumes:index') +VOLUME_CG_SNAPSHOTS_TAB_URL = urlunquote(reverse( + 'horizon:project:volumes:cg_snapshots_tab')) + + +class CGroupSnapshotTests(test.TestCase): + @test.create_stubs({cinder: ('volume_cg_snapshot_get', + 'volume_cgroup_create_from_source',)}) + def test_create_cgroup_from_snapshot(self): + cgroup = self.cinder_consistencygroups.first() + cg_snapshot = self.cinder_cg_snapshots.first() + formData = {'cg_snapshot_id': cg_snapshot.id, + 'name': 'test CG SS Create', + 'description': 'test desc'} + + cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\ + AndReturn(cg_snapshot) + cinder.volume_cgroup_create_from_source( + IsA(http.HttpRequest), + formData['name'], + source_cgroup_id=formData['cg_snapshot_id'], + description=formData['description'])\ + .AndReturn(cgroup) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:cg_snapshots:create_cgroup', + args=[cg_snapshot.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + + @test.create_stubs({cinder: ('volume_cg_snapshot_get', + 'volume_cgroup_create_from_source',)}) + def test_create_cgroup_from_snapshot_exception(self): + cg_snapshot = self.cinder_cg_snapshots.first() + new_cg_name = 'test CG SS Create' + formData = {'cg_snapshot_id': cg_snapshot.id, + 'name': new_cg_name, + 'description': 'test desc'} + + cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\ + AndReturn(cg_snapshot) + cinder.volume_cgroup_create_from_source( + IsA(http.HttpRequest), + formData['name'], + source_cgroup_id=formData['cg_snapshot_id'], + description=formData['description'])\ + .AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:cg_snapshots:create_cgroup', + args=[cg_snapshot.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + # There are a bunch of backslashes for formatting in the message from + # the response, so remove them when validating the error message. + self.assertIn('Unable to create consistency group "%s" from snapshot.' + % new_cg_name, + res.cookies.output().replace('\\', '')) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + + @test.create_stubs({cinder: ('volume_cg_snapshot_list', + 'volume_cg_snapshot_delete',)}) + def test_delete_cgroup_snapshot(self): + cg_snapshots = self.cinder_cg_snapshots.list() + cg_snapshot = self.cinder_cg_snapshots.first() + + cinder.volume_cg_snapshot_list(IsA(http.HttpRequest)).\ + AndReturn(cg_snapshots) + cinder.volume_cg_snapshot_delete(IsA(http.HttpRequest), cg_snapshot.id) + self.mox.ReplayAll() + + form_data = {'action': 'volume_cg_snapshots__delete_cg_snapshot__%s' + % cg_snapshot.id} + res = self.client.post(VOLUME_CG_SNAPSHOTS_TAB_URL, form_data, + follow=True) + self.assertEqual(res.status_code, 200) + self.assertIn("Scheduled deletion of Snapshot: %s" % cg_snapshot.name, + [m.message for m in res.context['messages']]) + + @test.create_stubs({cinder: ('volume_cg_snapshot_list', + 'volume_cg_snapshot_delete',)}) + def test_delete_cgroup_snapshot_exception(self): + cg_snapshots = self.cinder_cg_snapshots.list() + cg_snapshot = self.cinder_cg_snapshots.first() + + cinder.volume_cg_snapshot_list(IsA(http.HttpRequest)).\ + AndReturn(cg_snapshots) + cinder.volume_cg_snapshot_delete(IsA(http.HttpRequest), + cg_snapshot.id).\ + AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + form_data = {'action': 'volume_cg_snapshots__delete_cg_snapshot__%s' + % cg_snapshot.id} + res = self.client.post(VOLUME_CG_SNAPSHOTS_TAB_URL, form_data, + follow=True) + self.assertEqual(res.status_code, 200) + self.assertIn("Unable to delete snapshot: %s" % cg_snapshot.name, + [m.message for m in res.context['messages']]) + + @test.create_stubs({cinder: ('volume_cg_snapshot_get', + 'volume_cgroup_get', + 'volume_type_get', + 'volume_list',)}) + def test_detail_view(self): + cg_snapshot = self.cinder_cg_snapshots.first() + cgroup = self.cinder_consistencygroups.first() + volume_type = self.cinder_volume_types.first() + volumes = self.cinder_volumes.list() + + cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\ + AndReturn(cg_snapshot) + cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\ + AndReturn(cgroup) + cinder.volume_type_get(IsA(http.HttpRequest), volume_type.id).\ + MultipleTimes().AndReturn(volume_type) + search_opts = {'consistencygroup_id': cgroup.id} + cinder.volume_list(IsA(http.HttpRequest), search_opts=search_opts).\ + AndReturn(volumes) + + self.mox.ReplayAll() + + url = reverse( + 'horizon:project:volumes:cg_snapshots:cg_snapshot_detail', + args=[cg_snapshot.id]) + res = self.client.get(url) + self.assertNoFormErrors(res) + self.assertEqual(res.status_code, 200) + + @test.create_stubs({cinder: ('volume_cg_snapshot_get',)}) + def test_detail_view_with_exception(self): + cg_snapshot = self.cinder_cg_snapshots.first() + + cinder.volume_cg_snapshot_get(IsA(http.HttpRequest), cg_snapshot.id).\ + AndRaise(self.exceptions.cinder) + + self.mox.ReplayAll() + + url = reverse( + 'horizon:project:volumes:cg_snapshots:cg_snapshot_detail', + args=[cg_snapshot.id]) + res = self.client.get(url) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/urls.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/urls.py new file mode 100644 index 000000000..35446dbf2 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/urls.py @@ -0,0 +1,26 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.conf.urls import patterns +from django.conf.urls import url + +from openstack_dashboard.dashboards.project.volumes.cg_snapshots import views + +urlpatterns = patterns( + '', + url(r'^(?P[^/]+)/cg_snapshot_detail/$', + views.DetailView.as_view(), + name='cg_snapshot_detail'), + url(r'^(?P[^/]+)/create_cgroup/$', + views.CreateCGroupView.as_view(), + name='create_cgroup'), +) diff --git a/openstack_dashboard/dashboards/project/volumes/cg_snapshots/views.py b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/views.py new file mode 100644 index 000000000..28428f90b --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/cg_snapshots/views.py @@ -0,0 +1,138 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tabs +from horizon.utils import memoized + +from openstack_dashboard import api +from openstack_dashboard.api import cinder +from openstack_dashboard.usage import quotas + +from openstack_dashboard.dashboards.project.volumes \ + .cg_snapshots import forms as cg_snapshot_forms +from openstack_dashboard.dashboards.project.volumes \ + .cg_snapshots import tables as cg_snapshot_tables +from openstack_dashboard.dashboards.project.volumes \ + .cg_snapshots import tabs as cg_snapshot_tabs + +CGROUP_INFO_FIELDS = ("name", + "description") + +INDEX_URL = "horizon:project:volumes:index" + + +class DetailView(tabs.TabView): + tab_group_class = cg_snapshot_tabs.CGSnapshotsDetailTabs + template_name = 'horizon/common/_detail.html' + page_title = "{{ cg_snapshot.name|default:cg_snapshot.id }}" + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + cg_snapshot = self.get_data() + table = cg_snapshot_tables.CGSnapshotsTable(self.request) + context["cg_snapshot"] = cg_snapshot + context["url"] = self.get_redirect_url() + context["actions"] = table.render_row_actions(cg_snapshot) + return context + + @memoized.memoized_method + def get_data(self): + try: + cg_snapshot_id = self.kwargs['cg_snapshot_id'] + cg_snapshot = api.cinder.volume_cg_snapshot_get(self.request, + cg_snapshot_id) + + cgroup_id = cg_snapshot.consistencygroup_id + cgroup = api.cinder.volume_cgroup_get(self.request, + cgroup_id) + cg_snapshot.cg_name = cgroup.name + cg_snapshot.volume_type_names = [] + for vol_type_id in cgroup.volume_types: + vol_type = api.cinder.volume_type_get(self.request, + vol_type_id) + cg_snapshot.volume_type_names.append(vol_type.name) + + cg_snapshot.volume_names = [] + search_opts = {'consistencygroup_id': cgroup_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + for volume in volumes: + cg_snapshot.volume_names.append(volume.name) + + except Exception: + redirect = self.get_redirect_url() + exceptions.handle(self.request, + _('Unable to retrieve consistency group ' + 'snapshot details.'), + redirect=redirect) + return cg_snapshot + + @staticmethod + def get_redirect_url(): + return reverse('horizon:project:volumes:index') + + def get_tabs(self, request, *args, **kwargs): + cg_snapshot = self.get_data() + return self.tab_group_class(request, cg_snapshot=cg_snapshot, **kwargs) + + +class CreateCGroupView(forms.ModalFormView): + form_class = cg_snapshot_forms.CreateCGroupForm + modal_header = _("Create Consistency Group") + template_name = 'project/volumes/cg_snapshots/create.html' + submit_url = "horizon:project:volumes:cg_snapshots:create_cgroup" + success_url = reverse_lazy('horizon:project:volumes:cgroups_tab') + page_title = _("Create a Volume Consistency Group from Snapshot") + + def get_context_data(self, **kwargs): + context = super(CreateCGroupView, self).get_context_data(**kwargs) + context['cg_snapshot_id'] = self.kwargs['cg_snapshot_id'] + args = (self.kwargs['cg_snapshot_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + try: + # get number of volumes we will be creating + cg_snapshot = cinder.volume_cg_snapshot_get( + self.request, context['cg_snapshot_id']) + + cgroup_id = cg_snapshot.consistencygroup_id + + search_opts = {'consistencygroup_id': cgroup_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + num_volumes = len(volumes) + usages = quotas.tenant_limit_usages(self.request) + + if usages['volumesUsed'] + num_volumes > \ + usages['maxTotalVolumes']: + raise ValueError(_('Unable to create consistency group due to ' + 'exceeding volume quota limit.')) + else: + usages['numRequestedItems'] = num_volumes + context['usages'] = usages + + except ValueError as e: + exceptions.handle(self.request, e.message) + return None + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve consistency ' + 'group information.')) + return context + + def get_initial(self): + return {'cg_snapshot_id': self.kwargs["cg_snapshot_id"]} diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py b/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py index d857281b8..cf3bfd7c9 100644 --- a/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/forms.py @@ -28,8 +28,21 @@ class UpdateForm(forms.SelfHandlingForm): label=_("Description"), required=False) + def clean(self): + cleaned_data = super(UpdateForm, self).clean() + new_desc = cleaned_data.get('description') + old_desc = self.initial['description'] + if len(old_desc): + if len(new_desc) == 0: + error_msg = _("Description is required.") + self._errors['description'] = self.error_class([error_msg]) + return cleaned_data + + return cleaned_data + def handle(self, request, data): cgroup_id = self.initial['cgroup_id'] + try: cinder.volume_cgroup_update(request, cgroup_id, @@ -45,3 +58,160 @@ class UpdateForm(forms.SelfHandlingForm): exceptions.handle(request, _('Unable to update volume consistency group.'), redirect=redirect) + + +class RemoveVolsForm(forms.SelfHandlingForm): + def handle(self, request, data): + cgroup_id = self.initial['cgroup_id'] + name = self.initial['name'] + search_opts = {'consistencygroup_id': cgroup_id} + + try: + # get list of assigned volumes + assigned_vols = [] + volumes = cinder.volume_list(request, + search_opts=search_opts) + for volume in volumes: + assigned_vols.append(volume.id) + + assigned_vols_str = ",".join(assigned_vols) + cinder.volume_cgroup_update(request, + cgroup_id, + remove_vols=assigned_vols_str) + + message = _('Removing volumes from volume consistency ' + 'group "%s"') % name + messages.info(request, message) + return True + + except Exception: + redirect = reverse("horizon:project:volumes:index") + exceptions.handle(request, _('Errors occurred in removing volumes ' + 'from consistency group.'), + redirect=redirect) + + +class DeleteForm(forms.SelfHandlingForm): + delete_volumes = forms.BooleanField(label=_("Delete Volumes"), + required=False) + + def handle(self, request, data): + cgroup_id = self.initial['cgroup_id'] + name = self.initial['name'] + delete_volumes = data['delete_volumes'] + + try: + cinder.volume_cgroup_delete(request, + cgroup_id, + force=delete_volumes) + message = _('Deleting volume consistency ' + 'group "%s"') % name + messages.success(request, message) + return True + + except Exception: + redirect = reverse("horizon:project:volumes:index") + exceptions.handle(request, _('Errors occurred in deleting ' + 'consistency group.'), + redirect=redirect) + + +class CreateSnapshotForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Snapshot Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + + def __init__(self, request, *args, **kwargs): + super(CreateSnapshotForm, self).__init__(request, *args, **kwargs) + + # populate cgroup_id + cgroup_id = kwargs.get('initial', {}).get('cgroup_id', []) + self.fields['cgroup_id'] = forms.CharField(widget=forms.HiddenInput(), + initial=cgroup_id) + + def handle(self, request, data): + try: + message = _('Creating consistency group snapshot "%s".') \ + % data['name'] + snapshot = cinder.volume_cg_snapshot_create(request, + data['cgroup_id'], + data['name'], + data['description']) + + messages.info(request, message) + return snapshot + except Exception as e: + redirect = reverse("horizon:project:volumes:index") + msg = _('Unable to create consistency group snapshot.') + if e.code == 413: + msg = _('Requested snapshot would exceed the allowed quota.') + else: + search_opts = {'consistentcygroup_id': data['cgroup_id']} + volumes = cinder.volume_list(request, search_opts=search_opts) + if len(volumes) == 0: + msg = _('Unable to create snapshot. Consistency group ' + 'must contain volumes.') + + exceptions.handle(request, + msg, + redirect=redirect) + + +class CloneCGroupForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Consistency Group Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + cgroup_source = forms.ChoiceField( + label=_("Use a consistency group as source"), + widget=forms.SelectWidget( + attrs={'class': 'image-selector'}, + data_attrs=('name'), + transform=lambda x: "%s" % (x.name)), + required=False) + + def prepare_cgroup_source_field(self, request, cgroup_id): + try: + cgroup = cinder.volume_cgroup_get(request, + cgroup_id) + self.fields['cgroup_source'].choices = ((cgroup_id, + cgroup),) + except Exception: + exceptions.handle(request, _('Unable to load the specified ' + 'consistency group.')) + + def __init__(self, request, *args, **kwargs): + super(CloneCGroupForm, self).__init__(request, *args, **kwargs) + + # populate cgroup_id + cgroup_id = kwargs.get('initial', {}).get('cgroup_id', []) + self.fields['cgroup_id'] = forms.CharField(widget=forms.HiddenInput(), + initial=cgroup_id) + self.prepare_cgroup_source_field(request, cgroup_id) + + def handle(self, request, data): + try: + message = _('Creating consistency group "%s".') % data['name'] + cgroup = cinder.volume_cgroup_create_from_source( + request, + data['name'], + source_cgroup_id=data['cgroup_id'], + description=data['description']) + + messages.info(request, message) + return cgroup + except Exception: + redirect = reverse("horizon:project:volumes:index") + msg = _('Unable to clone consistency group.') + + search_opts = {'consistentcygroup_id': data['cgroup_id']} + volumes = cinder.volume_list(request, search_opts=search_opts) + if len(volumes) == 0: + msg = _('Unable to clone empty consistency group.') + + exceptions.handle(request, + msg, + redirect=redirect) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py b/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py index f02402b56..8253c92d3 100644 --- a/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/tables.py @@ -10,10 +10,8 @@ # License for the specific language governing permissions and limitations # under the License. -from django.core.urlresolvers import reverse from django.utils.translation import pgettext_lazy from django.utils.translation import ugettext_lazy as _ -from django.utils.translation import ungettext_lazy from horizon import exceptions from horizon import tables @@ -31,36 +29,20 @@ class CreateVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction): policy_rules = (("volume", "consistencygroup:create"),) -class DeleteVolumeCGroup(policy.PolicyTargetMixin, tables.DeleteAction): +class DeleteVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction): name = "deletecg" + verbose_name = _("Delete Consistency Group") + url = "horizon:project:volumes:cgroups:delete" + classes = ("ajax-modal", "btn-danger") policy_rules = (("volume", "consistencygroup:delete"), ) - @staticmethod - def action_present(count): - return ungettext_lazy( - u"Delete Consistency Group", - u"Delete Consistency Groups", - count - ) - @staticmethod - def action_past(count): - return ungettext_lazy( - u"Scheduled deletion of Consistency Group", - u"Scheduled deletion of Consistency Groups", - count - ) - - def delete(self, request, cgroup_id): - try: - cinder.volume_cgroup_delete(request, - cgroup_id, - force=False) - except Exception: - redirect = reverse("horizon:project:volumes:index") - exceptions.handle(request, - _('Unable to delete consistency group.'), - redirect=redirect) +class RemoveAllVolumes(policy.PolicyTargetMixin, tables.LinkAction): + name = "remove_vols" + verbose_name = _("Remove Volumes from Consistency Group") + url = "horizon:project:volumes:cgroups:remove_volumes" + classes = ("ajax-modal",) + policy_rules = (("volume", "consistencygroup:update"), ) class EditVolumeCGroup(policy.PolicyTargetMixin, tables.LinkAction): @@ -78,12 +60,51 @@ class ManageVolumes(policy.PolicyTargetMixin, tables.LinkAction): classes = ("ajax-modal",) policy_rules = (("volume", "consistencygroup:update"),) + def allowed(self, request, cgroup=None): + if hasattr(cgroup, 'status'): + return cgroup.status != 'error' + else: + return False + + +class CreateSnapshot(policy.PolicyTargetMixin, tables.LinkAction): + name = "create_snapshot" + verbose_name = _("Create Snapshot") + url = "horizon:project:volumes:cgroups:create_snapshot" + classes = ("ajax-modal",) + policy_rules = (("volume", "consistencygroup:create_cgsnapshot"),) + + def allowed(self, request, cgroup=None): + if hasattr(cgroup, 'status'): + return cgroup.status != 'error' + else: + return False + + +class CloneCGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "clone_cgroup" + verbose_name = _("Clone Consistency Group") + url = "horizon:project:volumes:cgroups:clone_cgroup" + classes = ("ajax-modal",) + policy_rules = (("volume", "consistencygroup:create"),) + + def allowed(self, request, cgroup=None): + if hasattr(cgroup, 'status'): + return cgroup.status != 'error' + else: + return False + class UpdateRow(tables.Row): ajax = True def get_data(self, request, cgroup_id): - cgroup = cinder.volume_cgroup_get(request, cgroup_id) + try: + cgroup = cinder.volume_cgroup_get_with_vol_type_names(request, + cgroup_id) + except Exception: + exceptions.handle(request, _('Unable to display ' + 'consistency group.')) return cgroup @@ -97,7 +118,9 @@ class VolumeCGroupsFilterAction(tables.FilterAction): def get_volume_types(cgroup): - vtypes_str = ",".join(cgroup.volume_type_names) + vtypes_str = '' + if hasattr(cgroup, 'volume_type_names'): + vtypes_str = ",".join(cgroup.volume_type_names) return vtypes_str @@ -143,6 +166,9 @@ class VolumeCGroupsTable(tables.DataTable): VolumeCGroupsFilterAction) row_actions = (ManageVolumes, EditVolumeCGroup, + CreateSnapshot, + CloneCGroup, + RemoveAllVolumes, DeleteVolumeCGroup) row_class = UpdateRow status_columns = ("status",) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py b/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py index 800210070..bd214f644 100644 --- a/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/tests.py @@ -10,7 +10,6 @@ # License for the specific language governing permissions and limitations # under the License. -import django from django.core.urlresolvers import reverse from django import http from django.utils.http import urlunquote @@ -23,34 +22,36 @@ from openstack_dashboard.test import helpers as test VOLUME_INDEX_URL = reverse('horizon:project:volumes:index') VOLUME_CGROUPS_TAB_URL = urlunquote(reverse( 'horizon:project:volumes:cgroups_tab')) +VOLUME_CGROUPS_SNAP_TAB_URL = urlunquote(reverse( + 'horizon:project:volumes:cg_snapshots_tab')) class ConsistencyGroupTests(test.TestCase): - @test.create_stubs({cinder: ('volume_cgroup_create', - 'volume_cgroup_list', + @test.create_stubs({cinder: ('extension_supported', + 'availability_zone_list', 'volume_type_list', 'volume_type_list_with_qos_associations', - 'availability_zone_list', - 'extension_supported')}) + 'volume_cgroup_list', + 'volume_cgroup_create')}) def test_create_cgroup(self): cgroup = self.cinder_consistencygroups.first() volume_types = self.cinder_volume_types.list() + volume_type_id = self.cinder_volume_types.first().id az = self.cinder_availability_zones.first().zoneName formData = {'volume_types': '1', 'name': 'test CG', 'description': 'test desc', - 'availability_zone': az} + 'availability_zone': az, + 'add_vtypes_to_cgroup_role_member': [volume_type_id]} - cinder.volume_type_list(IsA(http.HttpRequest)).\ - AndReturn(volume_types) - cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\ - AndReturn(volume_types) - cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( - self.cinder_availability_zones.list()) cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ .AndReturn(True) - cinder.volume_cgroup_list(IsA( - http.HttpRequest)).\ + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_type_list(IsA(http.HttpRequest)).AndReturn(volume_types) + cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\ + AndReturn(volume_types) + cinder.volume_cgroup_list(IsA(http.HttpRequest)).\ AndReturn(self.cinder_consistencygroups.list()) cinder.volume_cgroup_create( IsA(http.HttpRequest), @@ -64,31 +65,32 @@ class ConsistencyGroupTests(test.TestCase): url = reverse('horizon:project:volumes:cgroups:create') res = self.client.post(url, formData) self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) - @test.create_stubs({cinder: ('volume_cgroup_create', - 'volume_cgroup_list', + @test.create_stubs({cinder: ('extension_supported', + 'availability_zone_list', 'volume_type_list', 'volume_type_list_with_qos_associations', - 'availability_zone_list', - 'extension_supported')}) + 'volume_cgroup_list', + 'volume_cgroup_create')}) def test_create_cgroup_exception(self): volume_types = self.cinder_volume_types.list() + volume_type_id = self.cinder_volume_types.first().id az = self.cinder_availability_zones.first().zoneName formData = {'volume_types': '1', 'name': 'test CG', 'description': 'test desc', - 'availability_zone': az} + 'availability_zone': az, + 'add_vtypes_to_cgroup_role_member': [volume_type_id]} - cinder.volume_type_list(IsA(http.HttpRequest)).\ - AndReturn(volume_types) - cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\ - AndReturn(volume_types) - cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( - self.cinder_availability_zones.list()) cinder.extension_supported(IsA(http.HttpRequest), 'AvailabilityZones')\ .AndReturn(True) - cinder.volume_cgroup_list(IsA( - http.HttpRequest)).\ + cinder.availability_zone_list(IsA(http.HttpRequest)).AndReturn( + self.cinder_availability_zones.list()) + cinder.volume_type_list(IsA(http.HttpRequest)).AndReturn(volume_types) + cinder.volume_type_list_with_qos_associations(IsA(http.HttpRequest)).\ + AndReturn(volume_types) + cinder.volume_cgroup_list(IsA(http.HttpRequest)).\ AndReturn(self.cinder_consistencygroups.list()) cinder.volume_cgroup_create( IsA(http.HttpRequest), @@ -101,29 +103,65 @@ class ConsistencyGroupTests(test.TestCase): url = reverse('horizon:project:volumes:cgroups:create') res = self.client.post(url, formData) - + self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + self.assertIn("Unable to create consistency group.", + res.cookies.output()) - @test.create_stubs({cinder: ('volume_cgroup_list_with_vol_type_names', + @test.create_stubs({cinder: ('volume_cgroup_get', 'volume_cgroup_delete')}) def test_delete_cgroup(self): - cgroups = self.cinder_consistencygroups.list() cgroup = self.cinder_consistencygroups.first() - cinder.volume_cgroup_list_with_vol_type_names(IsA(http.HttpRequest)).\ - AndReturn(cgroups) + cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\ + AndReturn(cgroup) cinder.volume_cgroup_delete(IsA(http.HttpRequest), cgroup.id, force=False) - if django.VERSION < (1, 9): - cinder.volume_cgroup_list_with_vol_type_names( - IsA(http.HttpRequest)).AndReturn(cgroups) - self.mox.ReplayAll() - formData = {'action': 'volume_cgroups__deletecg__%s' % cgroup.id} - res = self.client.post(VOLUME_CGROUPS_TAB_URL, formData, follow=True) - self.assertIn("Scheduled deletion of Consistency Group: cg_1", - [m.message for m in res.context['messages']]) + url = reverse('horizon:project:volumes:cgroups:delete', + args=[cgroup.id]) + res = self.client.post(url) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + + @test.create_stubs({cinder: ('volume_cgroup_get', + 'volume_cgroup_delete')}) + def test_delete_cgroup_force_flag(self): + cgroup = self.cinder_consistencygroups.first() + formData = {'delete_volumes': True} + + cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\ + AndReturn(cgroup) + cinder.volume_cgroup_delete(IsA(http.HttpRequest), cgroup.id, + force=True) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:cgroups:delete', + args=[cgroup.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + + @test.create_stubs({cinder: ('volume_cgroup_get', + 'volume_cgroup_delete')}) + def test_delete_cgroup_exception(self): + cgroup = self.cinder_consistencygroups.first() + formData = {'delete_volumes': False} + + cinder.volume_cgroup_get(IsA(http.HttpRequest), cgroup.id).\ + AndReturn(cgroup) + cinder.volume_cgroup_delete(IsA(http.HttpRequest), + cgroup.id, + force=False).\ + AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:cgroups:delete', + args=[cgroup.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) @test.create_stubs({cinder: ('volume_cgroup_update', 'volume_cgroup_get')}) @@ -149,6 +187,7 @@ class ConsistencyGroupTests(test.TestCase): args=[cgroup.id]) res = self.client.post(url, formData) self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) @test.create_stubs({cinder: ('volume_cgroup_update', 'volume_cgroup_get')}) @@ -174,6 +213,7 @@ class ConsistencyGroupTests(test.TestCase): args=[cgroup.id]) res = self.client.post(url, formData) self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) @test.create_stubs({cinder: ('volume_cgroup_update', 'volume_cgroup_get')}) @@ -197,6 +237,7 @@ class ConsistencyGroupTests(test.TestCase): args=[cgroup.id]) res = self.client.post(url, formData) self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) @test.create_stubs({cinder: ('volume_cgroup_update', 'volume_cgroup_get')}) @@ -219,7 +260,7 @@ class ConsistencyGroupTests(test.TestCase): url = reverse('horizon:project:volumes:cgroups:update', args=[cgroup.id]) res = self.client.post(url, formData) - + self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) @test.create_stubs({cinder: ('volume_cgroup_get',)}) @@ -234,5 +275,48 @@ class ConsistencyGroupTests(test.TestCase): url = reverse('horizon:project:volumes:cgroups:detail', args=[cgroup.id]) res = self.client.get(url) - + self.assertNoFormErrors(res) self.assertRedirectsNoFollow(res, VOLUME_INDEX_URL) + + @test.create_stubs({cinder: ('volume_cg_snapshot_create',)}) + def test_create_snapshot(self): + cgroup = self.cinder_consistencygroups.first() + cg_snapshot = self.cinder_cg_snapshots.first() + formData = {'cgroup_id': cgroup.id, + 'name': 'test CG Snapshot', + 'description': 'test desc'} + + cinder.volume_cg_snapshot_create( + IsA(http.HttpRequest), + formData['cgroup_id'], + formData['name'], + formData['description'])\ + .AndReturn(cg_snapshot) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:cgroups:create_snapshot', + args=[cgroup.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_CGROUPS_SNAP_TAB_URL) + + @test.create_stubs({cinder: ('volume_cgroup_get', + 'volume_cgroup_create_from_source',)}) + def test_create_clone(self): + cgroup = self.cinder_consistencygroups.first() + formData = {'cgroup_id': cgroup.id, + 'name': 'test CG Clone', + 'description': 'test desc'} + cinder.volume_cgroup_create_from_source( + IsA(http.HttpRequest), + formData['name'], + source_cgroup_id=formData['cgroup_id'], + description=formData['description'])\ + .AndReturn(cgroup) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:cgroups:clone_cgroup', + args=[cgroup.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_CGROUPS_TAB_URL) diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py b/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py index b8fe8e023..51cddd71a 100644 --- a/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/urls.py @@ -22,9 +22,21 @@ urlpatterns = [ url(r'^(?P[^/]+)/update/$', views.UpdateView.as_view(), name='update'), + url(r'^(?P[^/]+)/remove_volumese/$', + views.RemoveVolumesView.as_view(), + name='remove_volumes'), + url(r'^(?P[^/]+)/delete/$', + views.DeleteView.as_view(), + name='delete'), url(r'^(?P[^/]+)/manage/$', views.ManageView.as_view(), name='manage'), + url(r'^(?P[^/]+)/create_snapshot/$', + views.CreateSnapshotView.as_view(), + name='create_snapshot'), + url(r'^(?P[^/]+)/clone_cgroup/$', + views.CloneCGroupView.as_view(), + name='clone_cgroup'), 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 index c20665dc4..5f120e122 100644 --- a/openstack_dashboard/dashboards/project/volumes/cgroups/views.py +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/views.py @@ -22,6 +22,7 @@ from horizon import workflows from openstack_dashboard import api from openstack_dashboard.api import cinder +from openstack_dashboard.usage import quotas from openstack_dashboard.dashboards.project.volumes \ .cgroups import workflows as vol_cgroup_workflows @@ -50,7 +51,7 @@ class UpdateView(forms.ModalFormView): form_class = vol_cgroup_forms.UpdateForm success_url = reverse_lazy('horizon:project:volumes:index') submit_url = "horizon:project:volumes:cgroups:update" - submit_label = modal_header + submit_label = _("Submit") page_title = modal_header def get_initial(self): @@ -78,6 +79,72 @@ class UpdateView(forms.ModalFormView): return self._object +class RemoveVolumesView(forms.ModalFormView): + template_name = 'project/volumes/cgroups/remove_vols.html' + modal_header = _("Remove Volumes from Consistency Group") + form_class = vol_cgroup_forms.RemoveVolsForm + success_url = reverse_lazy('horizon:project:volumes:index') + submit_url = "horizon:project:volumes:cgroups:remove_volumes" + submit_label = _("Submit") + page_title = modal_header + + def get_initial(self): + cgroup = self.get_object() + return {'cgroup_id': self.kwargs["cgroup_id"], + 'name': cgroup.name} + + def get_context_data(self, **kwargs): + context = super(RemoveVolumesView, self).get_context_data(**kwargs) + context['cgroup_id'] = self.kwargs['cgroup_id'] + args = (self.kwargs['cgroup_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_object(self): + cgroup_id = self.kwargs['cgroup_id'] + try: + self._object = cinder.volume_cgroup_get(self.request, cgroup_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve consistency group ' + 'details.'), + redirect=reverse(INDEX_URL)) + return self._object + + +class DeleteView(forms.ModalFormView): + template_name = 'project/volumes/cgroups/delete.html' + modal_header = _("Delete Consistency Group") + form_class = vol_cgroup_forms.DeleteForm + success_url = reverse_lazy('horizon:project:volumes:index') + submit_url = "horizon:project:volumes:cgroups:delete" + submit_label = modal_header + page_title = modal_header + + def get_initial(self): + cgroup = self.get_object() + return {'cgroup_id': self.kwargs["cgroup_id"], + 'name': cgroup.name} + + def get_context_data(self, **kwargs): + context = super(DeleteView, self).get_context_data(**kwargs) + context['cgroup_id'] = self.kwargs['cgroup_id'] + args = (self.kwargs['cgroup_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_object(self): + cgroup_id = self.kwargs['cgroup_id'] + try: + self._object = cinder.volume_cgroup_get(self.request, cgroup_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve consistency group ' + 'details.'), + redirect=reverse(INDEX_URL)) + return self._object + + class ManageView(workflows.WorkflowView): workflow_class = vol_cgroup_workflows.UpdateCGroupWorkflow @@ -105,6 +172,94 @@ class ManageView(workflows.WorkflowView): 'vtypes': getattr(cgroup, "volume_types")} +class CreateSnapshotView(forms.ModalFormView): + form_class = vol_cgroup_forms.CreateSnapshotForm + modal_header = _("Create Consistency Group Snapshot") + template_name = 'project/volumes/cgroups/create_snapshot.html' + submit_label = _("Create Snapshot") + submit_url = "horizon:project:volumes:cgroups:create_snapshot" + success_url = reverse_lazy('horizon:project:volumes:cg_snapshots_tab') + page_title = modal_header + + def get_context_data(self, **kwargs): + context = super(CreateSnapshotView, self).get_context_data(**kwargs) + context['cgroup_id'] = self.kwargs['cgroup_id'] + args = (self.kwargs['cgroup_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + try: + # get number of snapshots we will be creating + search_opts = {'consistencygroup_id': context['cgroup_id']} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + num_volumes = len(volumes) + usages = quotas.tenant_limit_usages(self.request) + + if usages['snapshotsUsed'] + num_volumes > \ + usages['maxTotalSnapshots']: + raise ValueError(_('Unable to create snapshots due to ' + 'exceeding snapshot quota limit.')) + else: + usages['numRequestedItems'] = num_volumes + context['usages'] = usages + + except ValueError as e: + exceptions.handle(self.request, e.message) + return None + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve consistency ' + 'group information.')) + return context + + def get_initial(self): + return {'cgroup_id': self.kwargs["cgroup_id"]} + + +class CloneCGroupView(forms.ModalFormView): + form_class = vol_cgroup_forms.CloneCGroupForm + modal_header = _("Clone Consistency Group") + template_name = 'project/volumes/cgroups/clone_cgroup.html' + submit_label = _("Clone Consistency Group") + submit_url = "horizon:project:volumes:cgroups:clone_cgroup" + success_url = reverse_lazy('horizon:project:volumes:cgroups_tab') + page_title = modal_header + + def get_context_data(self, **kwargs): + context = super(CloneCGroupView, self).get_context_data(**kwargs) + context['cgroup_id'] = self.kwargs['cgroup_id'] + args = (self.kwargs['cgroup_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + try: + # get number of volumes we will be creating + cgroup_id = context['cgroup_id'] + + search_opts = {'consistencygroup_id': cgroup_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + num_volumes = len(volumes) + usages = quotas.tenant_limit_usages(self.request) + + if usages['volumesUsed'] + num_volumes > \ + usages['maxTotalVolumes']: + raise ValueError(_('Unable to create consistency group due to ' + 'exceeding volume quota limit.')) + else: + usages['numRequestedItems'] = num_volumes + context['usages'] = usages + + except ValueError as e: + exceptions.handle(self.request, e.message) + return None + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve consistency ' + 'group information.')) + return context + + def get_initial(self): + return {'cgroup_id': self.kwargs["cgroup_id"]} + + class DetailView(tabs.TabView): tab_group_class = vol_cgroup_tabs.CGroupsDetailTabs template_name = 'horizon/common/_detail.html' diff --git a/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py b/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py index 841702d82..6a2fa36e0 100644 --- a/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py +++ b/openstack_dashboard/dashboards/project/volumes/cgroups/workflows.py @@ -15,7 +15,6 @@ from django.utils.translation import ugettext_lazy as _ from horizon import exceptions from horizon import forms -from horizon import messages from horizon import workflows from openstack_dashboard import api @@ -100,10 +99,14 @@ class AddCGroupInfoAction(workflows.Action): if cgroups is not None and name is not None: for cgroup in cgroups: if cgroup.name.lower() == name.lower(): + # ensure new name has reasonable length + formatted_name = name + if len(name) > 20: + formatted_name = name[:14] + "..." + name[-3:] raise forms.ValidationError( _('The name "%s" is already used by ' 'another consistency group.') - % name + % formatted_name ) return cleaned_data @@ -136,19 +139,25 @@ class AddVolumeTypesToCGroupAction(workflows.MembershipAction): except Exception: exceptions.handle(request, err_msg) - vtype_names = [] - for vtype in vtypes: - if vtype.name not in vtype_names: - vtype_names.append(vtype.name) - vtype_names.sort() - - self.fields[field_name].choices = \ - [(vtype_name, vtype_name) for vtype_name in vtype_names] + vtype_list = [(vtype.id, vtype.name) + for vtype in vtypes] + self.fields[field_name].choices = vtype_list class Meta(object): name = _("Manage Volume Types") slug = "add_vtypes_to_cgroup" + def clean(self): + cleaned_data = super(AddVolumeTypesToCGroupAction, self).clean() + volume_types = cleaned_data.get('add_vtypes_to_cgroup_role_member') + if not volume_types: + raise forms.ValidationError( + _('At least one volume type must be assigned ' + 'to a consistency group.') + ) + + return cleaned_data + class AddVolTypesToCGroupStep(workflows.UpdateMembersStep): action_class = AddVolumeTypesToCGroupAction @@ -198,15 +207,28 @@ class AddVolumesToCGroupAction(workflows.MembershipAction): volumes = cinder.volume_list(request) for volume in volumes: if volume.volume_type in vtype_names: + cgroup_id = None + vol_is_available = False in_this_cgroup = False if hasattr(volume, 'consistencygroup_id'): - if volume.consistencygroup_id == \ - self.initial['cgroup_id']: - in_this_cgroup = True - vol_list.append({'volume_name': volume.name, - 'volume_id': volume.id, - 'in_cgroup': in_this_cgroup, - 'is_duplicate': False}) + # this vol already belongs to a CG. + # only include it here if it belongs to this CG + cgroup_id = volume.consistencygroup_id + + if not cgroup_id: + # put this vol in the available list + vol_is_available = True + elif cgroup_id == self.initial['cgroup_id']: + # put this vol in the assigned to CG list + vol_is_available = True + in_this_cgroup = True + + if vol_is_available: + vol_list.append({'volume_name': volume.name, + 'volume_id': volume.id, + 'in_cgroup': in_this_cgroup, + 'is_duplicate': False}) + sorted_vol_list = sorted(vol_list, key=lambda k: k['volume_name']) # mark any duplicate volume names @@ -228,9 +250,9 @@ class AddVolumesToCGroupAction(workflows.MembershipAction): " [" + volume['volume_id'] + "]" else: entry = volume['volume_name'] - available_vols.append((entry, entry)) + available_vols.append((volume['volume_id'], entry)) if volume['in_cgroup']: - assigned_vols.append(entry) + assigned_vols.append(volume['volume_id']) except Exception: exceptions.handle(request, err_msg) @@ -291,7 +313,7 @@ class CreateCGroupWorkflow(workflows.Workflow): for selected_vol_type in selected_vol_types: if not invalid_backend: for vol_type in vol_types: - if selected_vol_type == vol_type.name: + if selected_vol_type == vol_type.id: if hasattr(vol_type, "extra_specs"): vol_type_backend = \ vol_type.extra_specs['volume_backend_name'] @@ -319,7 +341,7 @@ class CreateCGroupWorkflow(workflows.Workflow): cinder.volume_cgroup_create( request, vtypes_str, - name=context['name'], + context['name'], description=context['description'], availability_zone=context['availability_zone']) except Exception: @@ -331,11 +353,11 @@ class CreateCGroupWorkflow(workflows.Workflow): class UpdateCGroupWorkflow(workflows.Workflow): - slug = "create_cgroup" + slug = "update_cgroup" name = _("Add/Remove Consistency Group Volumes") - finalize_button_name = _("Edit Consistency Group") - success_message = _('Edit consistency group "%s".') - failure_message = _('Unable to edit consistency group') + finalize_button_name = _("Submit") + success_message = _('Updated volumes for consistency group "%s".') + failure_message = _('Unable to update volumes for consistency group') success_url = INDEX_URL default_steps = (AddVolumesToCGroupStep,) @@ -351,23 +373,8 @@ class UpdateCGroupWorkflow(workflows.Workflow): for volume in volumes: selected = False for selection in selected_volumes: - if " [" in selection: - # handle duplicate volume names - sel = selection.split(" [") - sel_vol_name = sel[0] - sel_vol_id = sel[1].split("]")[0] - else: - sel_vol_name = selection - sel_vol_id = None - - if volume.name == sel_vol_name: - if sel_vol_id: - if sel_vol_id == volume.id: - selected = True - else: - selected = True - - if selected: + if selection == volume.id: + selected = True break if selected: @@ -381,43 +388,24 @@ class UpdateCGroupWorkflow(workflows.Workflow): # ensure this volume is not in our consistency group if hasattr(volume, 'consistencygroup_id'): if volume.consistencygroup_id == cgroup_id: + # remove from this CG remove_vols.append(volume.id) add_vols_str = ",".join(add_vols) remove_vols_str = ",".join(remove_vols) + + if not add_vols_str and not remove_vols_str: + # nothing to change + return True + cinder.volume_cgroup_update(request, cgroup_id, name=context['name'], add_vols=add_vols_str, remove_vols=remove_vols_str) - # before returning, ensure all new volumes are correctly assigned - self._verify_changes(request, cgroup_id, add_vols, remove_vols) - - message = _('Updating volume consistency ' - 'group "%s"') % context['name'] - messages.info(request, message) except Exception: - exceptions.handle(request, _('Unable to edit consistency group.')) + # error message supplied by form return False return True - - def _verify_changes(self, request, cgroup_id, add_vols, remove_vols): - search_opts = {'consistencygroup_id': cgroup_id} - done = False - while not done: - done = True - volumes = cinder.volume_list(request, - search_opts=search_opts) - assigned_vols = [] - for volume in volumes: - assigned_vols.append(volume.id) - - for add_vol in add_vols: - if add_vol not in assigned_vols: - done = False - - for remove_vol in remove_vols: - if remove_vol in assigned_vols: - done = False diff --git a/openstack_dashboard/dashboards/project/volumes/tabs.py b/openstack_dashboard/dashboards/project/volumes/tabs.py index 3b49f9d63..31b04eb26 100644 --- a/openstack_dashboard/dashboards/project/volumes/tabs.py +++ b/openstack_dashboard/dashboards/project/volumes/tabs.py @@ -24,8 +24,10 @@ from openstack_dashboard import policy from openstack_dashboard.dashboards.project.volumes.backups \ import tables as backups_tables +from openstack_dashboard.dashboards.project.volumes.cg_snapshots \ + import tables as cg_snapshots_tables from openstack_dashboard.dashboards.project.volumes.cgroups \ - import tables as vol_cgroup_tables + import tables as cgroup_tables from openstack_dashboard.dashboards.project.volumes.snapshots \ import tables as vol_snapshot_tables from openstack_dashboard.dashboards.project.volumes.volumes \ @@ -206,9 +208,9 @@ class BackupsTab(PagedTableMixin, tabs.TableTab, VolumeTableMixIn): return backups -class CGroupsTab(tabs.TableTab, VolumeTableMixIn): - table_classes = (vol_cgroup_tables.VolumeCGroupsTable,) - name = _("Volume Consistency Groups") +class CGroupsTab(tabs.TableTab): + table_classes = (cgroup_tables.VolumeCGroupsTable,) + name = _("Consistency Groups") slug = "cgroups_tab" template_name = ("horizon/common/_detail_table.html") preload = False @@ -223,8 +225,7 @@ class CGroupsTab(tabs.TableTab, VolumeTableMixIn): try: cgroups = api.cinder.volume_cgroup_list_with_vol_type_names( self.request) - for cgroup in cgroups: - setattr(cgroup, '_volume_tab', self.tab_group.tabs[0]) + except Exception: cgroups = [] exceptions.handle(self.request, _("Unable to retrieve " @@ -232,7 +233,32 @@ class CGroupsTab(tabs.TableTab, VolumeTableMixIn): return cgroups +class CGSnapshotsTab(tabs.TableTab): + table_classes = (cg_snapshots_tables.CGSnapshotsTable,) + name = _("Consistency Group Snapshots") + slug = "cg_snapshots_tab" + template_name = ("horizon/common/_detail_table.html") + preload = False + + def allowed(self, request): + return policy.check( + (("volume", "consistencygroup:get_all_cgsnapshots"),), + request + ) + + def get_volume_cg_snapshots_data(self): + try: + cg_snapshots = api.cinder.volume_cg_snapshot_list( + self.request) + except Exception: + cg_snapshots = [] + exceptions.handle(self.request, _("Unable to retrieve " + "volume consistency group " + "snapshots.")) + return cg_snapshots + + class VolumeAndSnapshotTabs(tabs.TabGroup): slug = "volumes_and_snapshots" - tabs = (VolumeTab, SnapshotTab, BackupsTab, CGroupsTab) + tabs = (VolumeTab, SnapshotTab, BackupsTab, CGroupsTab, CGSnapshotsTab) sticky = True diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/_volume_limits.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/_volume_limits.html new file mode 100644 index 000000000..4efd367d3 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/_volume_limits.html @@ -0,0 +1,63 @@ +{% load i18n horizon humanize bootstrap %} + +

{% block head %}{% trans "Volume Limits" %}{% endblock %}

+ +
+
+ {% trans "Total Gibibytes" %} + ({% block gigabytes_used %}{{ usages.gigabytesUsed|intcomma }}{% endblock %} {% trans "GiB" %}) +
+ {{ usages.maxTotalVolumeGigabytes|intcomma|quota:_("GiB") }} +
+ +{{ minifyspace }} +
+ {% widthratio usages.gigabytesUsed usages.maxTotalVolumeGigabytes 100 as gigabytes_percent %} + {% bs_progress_bar gigabytes_percent 0 %} +
+{{ endminifyspace }} + +
+
+ {% block type_title %}{% trans "Number of Volumes" %}{% endblock %} + ({% block used %}{{ usages.volumesUsed|intcomma }}{% endblock %}) +
+ {% block total %}{{ usages.maxTotalVolumes|intcomma|quota }}{% endblock %} +
+ +{{ minifyspace }} +
+ {% widthratio usages.volumesUsed usages.maxTotalVolumes 100 as volumes_percent %} + {% if usages.numRequestedItems %} + {% widthratio 100 usages.maxTotalVolumes usages.numRequestedItems as single_step %} + {% else %} + {% widthratio 100 usages.maxTotalVolumes 1 as single_step %} + {% endif %} + {% bs_progress_bar volumes_percent single_step %} +
+{{ endminifyspace }} + + diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_create.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_create.html new file mode 100644 index 000000000..ac3104a6d --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_create.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
+

{% blocktrans %}Create a Consistency Group that will contain newly created volumes cloned from each of the snapshots in the source Consistency Group Snapshot.{% endblocktrans %}

+ {% include "project/volumes/_volume_limits.html" with usages=usages %} +
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_detail_overview.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_detail_overview.html new file mode 100644 index 000000000..ae04110b1 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_detail_overview.html @@ -0,0 +1,46 @@ +{% load i18n sizeformat parse_date %} + +
+
+
{% trans "Name" %}
+
{{ cg_snapshot.name }}
+
{% trans "ID" %}
+
{{ cg_snapshot.id }}
+ {% if cg_snapshot.description %} +
{% trans "Description" %}
+
{{ cg_snapshot.description }}
+ {% endif %} +
{% trans "Status" %}
+
{{ cg_snapshot.status|capfirst }}
+
{% trans "Consistency Group" %}
+
+ + {% if cg_snapshot.cg_name %} + {{ cg_snapshot.cg_name }} + {% else %} + {{ cg_snapshot.consistencygroup_id }} + {% endif %} + +
+
+ +

{% trans "Snapshot Volume Types" %}

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

{% trans "Snapshot Volumes" %}

+
+
+ {% for vol_names in cg_snapshot.volume_names %} +
{{ vol_names }}
+ {% empty %} +
+ {% trans "No assigned volumes" %} +
+ {% endfor %} +
+
diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_update.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_update.html new file mode 100644 index 000000000..1e9415e2c --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/_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 snapshot." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/create.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/create.html new file mode 100644 index 000000000..7df3d94c1 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cg_snapshots/_create.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/update.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/update.html new file mode 100644 index 000000000..33b227b32 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cg_snapshots/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cg_snapshots/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_clone_cgroup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_clone_cgroup.html new file mode 100644 index 000000000..828803416 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_clone_cgroup.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
+

{% blocktrans %}Clone each of the volumes in the source Consistency Group, and then add them to a newly created Consistency Group.{% endblocktrans %}

+ {% include "project/volumes/_volume_limits.html" with usages=usages snapshot_quota=False %} +
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_create_snapshot.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_create_snapshot.html new file mode 100644 index 000000000..b40196144 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_create_snapshot.html @@ -0,0 +1,10 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
+

{% blocktrans %}Create a snapshot for each volume contained in the Consistency Group.{% endblocktrans %}

+

{% blocktrans %}Snapshots can only be created for Consistency Groups that contain volumes.{% endblocktrans %}

+ {% include "project/volumes/cgroups/_snapshot_limits.html" with usages=usages snapshot_quota=True %} +
+{% 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 index 94c18d42b..7443c26ec 100644 --- a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_delete.html +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_delete.html @@ -3,8 +3,7 @@ {% 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 "Volume consistency groups can not be deleted if they contain volumes." %}

+

{% trans "Check the "Delete Volumes" box to also delete any 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/_remove_vols.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_remove_vols.html new file mode 100644 index 000000000..2ea4749ff --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_remove_vols.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block modal-body %} +

{% trans "This action will unassign all volumes that are currently contained in this consistency group." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_snapshot_limits.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_snapshot_limits.html new file mode 100644 index 000000000..f7b9f6cfb --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/_snapshot_limits.html @@ -0,0 +1,42 @@ +{% extends "project/volumes/_volume_limits.html" %} +{% load i18n horizon humanize %} + +{% block title %} + {% trans "From here you can create a snapshot of a volume." %} +{% endblock %} + +{% block head %} + {% trans "Snapshot Limits" %} +{% endblock %} + +{% block gigabytes_used %} + {{ usages.totalGigabytesUsed|intcomma }} +{% endblock %} + +{% block gigabytes_used_progress %} + "{{ usages.totalGigabytesUsed }}" +{% endblock %} + +{% block type_title %} + {% trans "Number of Snapshots" %} +{% endblock %} + +{% block used %} + {{ usages.snapshotsUsed|intcomma }} +{% endblock %} + +{% block total %} + {{ usages.maxTotalSnapshots|intcomma|quota }} +{% endblock %} + +{% block type_id %} + "quota_snapshots" +{% endblock %} + +{% block total_progress %} + "{{ usages.maxTotalSnapshots }}" +{% endblock %} + +{% block used_progress %} + "{{ usages.snapshotsUsed }}" +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/clone_cgroup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/clone_cgroup.html new file mode 100644 index 000000000..3cb746da4 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/clone_cgroup.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cgroups/_clone_cgroup.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/create_snapshot.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/create_snapshot.html new file mode 100644 index 000000000..fc866c902 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/create_snapshot.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cgroups/_create_snapshot.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/remove_vols.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/remove_vols.html new file mode 100644 index 000000000..e358c3c49 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/cgroups/remove_vols.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/cgroups/_remove_vols.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_limits.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_limits.html index 6a3ce4023..c7d37d061 100644 --- a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_limits.html +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_limits.html @@ -47,7 +47,11 @@ data-quota-used={% block used_progress %}"{{ usages.volumesUsed }}"{% endblock %} class="quota_bar"> {% widthratio usages.volumesUsed usages.maxTotalVolumes 100 as volumes_percent %} - {% widthratio 100 usages.maxTotalVolumes 1 as single_step %} + {% if usages.numRequestedItems %} + {% widthratio 100 usages.maxTotalVolumes usages.numRequestedItems as single_step %} + {% else %} + {% widthratio 100 usages.maxTotalVolumes 1 as single_step %} + {% endif %} {% bs_progress_bar volumes_percent single_step %} {{ endminifyspace }} diff --git a/openstack_dashboard/dashboards/project/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/urls.py index ad4ef6814..ce9c70b6b 100644 --- a/openstack_dashboard/dashboards/project/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/urls.py @@ -17,6 +17,8 @@ from django.conf.urls import url from openstack_dashboard.dashboards.project.volumes.backups \ import urls as backups_urls +from openstack_dashboard.dashboards.project.volumes.cg_snapshots \ + import urls as cg_snapshots_urls from openstack_dashboard.dashboards.project.volumes.cgroups \ import urls as cgroup_urls from openstack_dashboard.dashboards.project.volumes.snapshots \ @@ -35,8 +37,21 @@ urlpatterns = [ views.IndexView.as_view(), name='backups_tab'), url(r'^\?tab=volumes_and_snapshots__cgroups_tab$', views.IndexView.as_view(), name='cgroups_tab'), - url(r'', include(volume_urls, namespace='volumes')), - url(r'backups/', include(backups_urls, namespace='backups')), - url(r'snapshots/', include(snapshot_urls, namespace='snapshots')), - url(r'cgroups/', include(cgroup_urls, namespace='cgroups')), + url(r'^\?tab=volumes_and_snapshots__cg_snapshots_tab$', + views.IndexView.as_view(), name='cg_snapshots_tab'), + url(r'', include( + volume_urls, + namespace='volumes')), + url(r'backups/', include( + backups_urls, + namespace='backups')), + url(r'snapshots/', include( + snapshot_urls, + namespace='snapshots')), + url(r'cgroups/', include( + cgroup_urls, + namespace='cgroups')), + url(r'cg_snapshots/', include( + cg_snapshots_urls, + namespace='cg_snapshots')), ] diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index caebbd380..edd0c5d01 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -111,6 +111,10 @@ class DeleteVolume(VolumePolicyTargetMixin, tables.DeleteAction): def allowed(self, request, volume=None): if volume: + # Can't delete volume if part of consistency group + if getattr(volume, 'consistencygroup_id', None): + return False + return (volume.status in DELETABLE_STATES and not getattr(volume, 'has_snapshot', False)) return True diff --git a/openstack_dashboard/test/api_tests/cinder_tests.py b/openstack_dashboard/test/api_tests/cinder_tests.py index 25f50d562..169e069ab 100644 --- a/openstack_dashboard/test/api_tests/cinder_tests.py +++ b/openstack_dashboard/test/api_tests/cinder_tests.py @@ -356,15 +356,13 @@ class CinderApiTests(test.APITestCase): def test_cgroup_list_with_vol_type_names(self): cgroups = self.cinder_consistencygroups.list() - cgroup = self.cinder_consistencygroups.first() volume_types_list = self.cinder_volume_types.list() cinderclient = self.stub_cinderclient() cinderclient.consistencygroups = self.mox.CreateMockAnything() cinderclient.consistencygroups.list(search_opts=None).\ AndReturn(cgroups) cinderclient.volume_types = self.mox.CreateMockAnything() - for volume_types in volume_types_list: - cinderclient.volume_types.get(cgroup.id).AndReturn(volume_types) + cinderclient.volume_types.list().AndReturn(volume_types_list) self.mox.ReplayAll() api_cgroups = api.cinder.volume_cgroup_list_with_vol_type_names( self.request) @@ -373,6 +371,29 @@ class CinderApiTests(test.APITestCase): self.assertEqual(volume_types_list[i].name, api_cgroups[0].volume_type_names[i]) + def test_cgsnapshot_list(self): + cgsnapshots = self.cinder_cg_snapshots.list() + cinderclient = self.stub_cinderclient() + cinderclient.cgsnapshots = self.mox.CreateMockAnything() + cinderclient.cgsnapshots.list(search_opts=None).\ + AndReturn(cgsnapshots) + self.mox.ReplayAll() + api_cgsnapshots = api.cinder.volume_cg_snapshot_list(self.request) + self.assertEqual(len(cgsnapshots), len(api_cgsnapshots)) + + def test_cgsnapshot_get(self): + cgsnapshot = self.cinder_cg_snapshots.first() + cinderclient = self.stub_cinderclient() + cinderclient.cgsnapshots = self.mox.CreateMockAnything() + cinderclient.cgsnapshots.get(cgsnapshot.id).AndReturn(cgsnapshot) + self.mox.ReplayAll() + api_cgsnapshot = api.cinder.volume_cg_snapshot_get(self.request, + cgsnapshot.id) + self.assertEqual(api_cgsnapshot.name, cgsnapshot.name) + self.assertEqual(api_cgsnapshot.description, cgsnapshot.description) + self.assertEqual(api_cgsnapshot.consistencygroup_id, + cgsnapshot.consistencygroup_id) + class CinderApiVersionTests(test.TestCase): diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index 7e8cdd469..3b9db426b 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 cgsnapshots from cinderclient.v2 import consistencygroups from cinderclient.v2 import pools from cinderclient.v2 import qos_specs @@ -49,6 +50,7 @@ def data(TEST): TEST.cinder_pools = utils.TestDataContainer() TEST.cinder_consistencygroups = utils.TestDataContainer() TEST.cinder_cgroup_volumes = utils.TestDataContainer() + TEST.cinder_cg_snapshots = utils.TestDataContainer() # Services service_1 = services.Service(services.ServiceManager(None), { @@ -148,7 +150,9 @@ def data(TEST): {'id': u'1', 'name': u'vol_type_1', 'description': 'type 1 description', - 'extra_specs': {'foo': 'bar'}}) + 'extra_specs': {'foo': 'bar', + 'volume_backend_name': + 'backend_1'}}) vol_type2 = volume_types.VolumeType(volume_types.VolumeTypeManager(None), {'id': u'2', 'name': u'vol_type_2', @@ -419,3 +423,12 @@ def data(TEST): 'consistencygroup_id': u'1'}) TEST.cinder_cgroup_volumes.add(api.cinder.Volume( volume_for_consistency_group)) + + # volume consistency group snapshots + cg_snapshot_1 = cgsnapshots.Cgsnapshot( + cgsnapshots.CgsnapshotManager(None), + {'id': u'1', + 'name': u'cg_ss_1', + 'description': 'cg_ss 1 description', + 'consistencygroup_id': u'1'}) + TEST.cinder_cg_snapshots.add(cg_snapshot_1) diff --git a/releasenotes/notes/bp-cinder-consistency-groups-7cc98fda0ff3bb7a.yaml b/releasenotes/notes/bp-cinder-consistency-groups-7cc98fda0ff3bb7a.yaml new file mode 100644 index 000000000..14085981f --- /dev/null +++ b/releasenotes/notes/bp-cinder-consistency-groups-7cc98fda0ff3bb7a.yaml @@ -0,0 +1,13 @@ +--- +features: + - > + [`blueprint cinder-consistency-groups `_] + This feature adds 2 new tabs to the Project Volumes panel. The first tab will display + Consistency Groups, and the second tab will display Consistency Group Snapshots. + Consistency Groups (CG) contain existing volumes, and allow the user to perform + actions on the volumes in one step. Actions include: create/update/delete CGs, + snapshot all volumes in a CG, clone all volumes in a CG, and create a new CG and + volumes from a CG snapshot. + + Policies associated with Consistency Groups exist in the Cinder policy file, and + by default, all actions are disabled.