Generic volume group support

This commit adds cinder generic group support.
Consistency group support are not shown if the generic group
support is available.

blueprint cinder-generic-volume-groups
Change-Id: I038eeaf2508926f18b6053db0082a8aa3f3e20c6
This commit is contained in:
Akihiro Motoki 2018-01-08 10:43:49 +09:00
parent ee95bf15d9
commit 32d463a298
43 changed files with 2940 additions and 50 deletions

View File

@ -136,6 +136,10 @@ class APIResourceWrapper(object):
obj[key] = getattr(self, key, None)
return obj
@property
def name_or_id(self):
return self.name or '(%s)' % self.id[:13]
class APIDictWrapper(object):
"""Simple wrapper for api dictionaries

View File

@ -87,7 +87,7 @@ class Volume(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'size', 'status', 'created_at',
'volume_type', 'availability_zone', 'imageRef', 'bootable',
'snapshot_id', 'source_volid', 'attachments', 'tenant_name',
'consistencygroup_id', 'os-vol-host-attr:host',
'group_id', 'consistencygroup_id', 'os-vol-host-attr:host',
'os-vol-tenant-attr:tenant_id', 'metadata',
'volume_image_metadata', 'encrypted', 'transfer',
'multiattach']
@ -172,6 +172,21 @@ class VolumePool(base.APIResourceWrapper):
'storage_protocol', 'extra_specs']
class Group(base.APIResourceWrapper):
_attrs = ['id', 'status', 'availability_zone', 'created_at', 'name',
'description', 'group_type', 'volume_types',
'group_snapshot_id', 'source_group_id', 'replication_status']
class GroupSnapshot(base.APIResourceWrapper):
_attrs = ['id', 'name', 'description', 'status', 'created_at',
'group_id', 'group_type_id']
class GroupType(base.APIResourceWrapper):
_attrs = ['id', 'name', 'description', 'is_public', 'group_specs']
def get_auth_params_from_request(request):
auth_url = base.url_for(request, 'identity')
cinder_urls = []
@ -248,6 +263,13 @@ def get_microversion(request, features):
'cinder', features, api_versions.APIVersion, min_ver, max_ver))
def _cinderclient_with_generic_groups(request):
version = get_microversion(request, 'groups')
if version is not None:
version = version.get_string()
return cinderclient(request, version=version)
def version_get():
api_version = VERSIONS.get_active_version()
return api_version['version']
@ -289,7 +311,8 @@ def volume_list_paged(request, search_opts=None, marker=None, paginate=False,
has_prev_data = False
volumes = []
c_client = cinderclient(request)
# To support filtering with group_id, we need to use the microversion.
c_client = _cinderclient_with_generic_groups(request)
if c_client is None:
return volumes, has_more_data, has_prev_data
@ -1079,3 +1102,162 @@ def volume_type_add_project_access(request, volume_type, project_id):
def volume_type_remove_project_access(request, volume_type, project_id):
return cinderclient(request).volume_type_access.remove_project_access(
volume_type, project_id)
@profiler.trace
def group_type_list(request):
client = _cinderclient_with_generic_groups(request)
return [GroupType(t) for t in client.group_types.list()]
@profiler.trace
def group_type_get(request, group_type_id):
client = _cinderclient_with_generic_groups(request)
return GroupType(client.group_types.get(group_type_id))
@profiler.trace
def group_type_create(request, name, description=None, is_public=None):
client = _cinderclient_with_generic_groups(request)
params = {'name': name}
if description is not None:
params['description'] = description
if is_public is not None:
params['is_public'] = is_public
return GroupType(client.group_types.create(**params))
@profiler.trace
def group_type_update(request, group_type_id, data):
client = _cinderclient_with_generic_groups(request)
return GroupType(client.group_types.update(group_type_id, **data))
@profiler.trace
def group_type_delete(request, group_type_id):
client = _cinderclient_with_generic_groups(request)
client.group_types.delete(group_type_id)
@profiler.trace
def group_type_spec_set(request, group_type_id, metadata):
client = _cinderclient_with_generic_groups(request)
client.group_types.set_keys(metadata)
@profiler.trace
def group_type_spec_unset(request, group_type_id, keys):
client = _cinderclient_with_generic_groups(request)
client.group_types.unset_keys(keys)
@profiler.trace
def group_list(request, search_opts=None):
client = _cinderclient_with_generic_groups(request)
return [Group(g) for g in client.groups.list(search_opts=search_opts)]
@profiler.trace
def group_list_with_vol_type_names(request, search_opts=None):
groups = group_list(request, search_opts)
vol_types = volume_type_list(request)
for group in groups:
group.volume_type_names = []
for vol_type_id in group.volume_types:
for vol_type in vol_types:
if vol_type.id == vol_type_id:
group.volume_type_names.append(vol_type.name)
break
return groups
@profiler.trace
def group_get(request, group_id):
client = _cinderclient_with_generic_groups(request)
group = client.groups.get(group_id)
return Group(group)
@profiler.trace
def group_get_with_vol_type_names(request, group_id):
group = group_get(request, group_id)
vol_types = volume_type_list(request)
group.volume_type_names = []
for vol_type_id in group.volume_types:
for vol_type in vol_types:
if vol_type.id == vol_type_id:
group.volume_type_names.append(vol_type.name)
break
return group
@profiler.trace
def group_create(request, name, group_type, volume_types,
description=None, availability_zone=None):
client = _cinderclient_with_generic_groups(request)
params = {'name': name,
'group_type': group_type,
# cinderclient expects a comma-separated list of volume types.
'volume_types': ','.join(volume_types)}
if description is not None:
params['description'] = description
if availability_zone is not None:
params['availability_zone'] = availability_zone
return Group(client.groups.create(**params))
@profiler.trace
def group_create_from_source(request, name, group_snapshot_id=None,
source_group_id=None, description=None,
user_id=None, project_id=None):
client = _cinderclient_with_generic_groups(request)
return Group(client.groups.create_from_src(
group_snapshot_id, source_group_id, name, description,
user_id, project_id))
@profiler.trace
def group_delete(request, group_id, delete_volumes=False):
client = _cinderclient_with_generic_groups(request)
client.groups.delete(group_id, delete_volumes)
@profiler.trace
def group_update(request, group_id, name=None, description=None,
add_volumes=None, remove_volumes=None):
data = {}
if name is not None:
data['name'] = name
if description is not None:
data['description'] = description
if add_volumes:
# cinderclient expects a comma-separated list of volume types.
data['add_volumes'] = ','.join(add_volumes)
if remove_volumes:
# cinderclient expects a comma-separated list of volume types.
data['remove_volumes'] = ','.join(remove_volumes)
client = _cinderclient_with_generic_groups(request)
return client.groups.update(group_id, **data)
def group_snapshot_create(request, group_id, name, description=None):
client = _cinderclient_with_generic_groups(request)
return GroupSnapshot(client.group_snapshots.create(group_id, name,
description))
def group_snapshot_get(request, group_snapshot_id):
client = _cinderclient_with_generic_groups(request)
return GroupSnapshot(client.group_snapshots.get(group_snapshot_id))
def group_snapshot_list(request, search_opts=None):
client = _cinderclient_with_generic_groups(request)
return [GroupSnapshot(s) for s
in client.group_snapshots.list(search_opts=search_opts)]
def group_snapshot_delete(request, group_snapshot_id):
client = _cinderclient_with_generic_groups(request)
client.group_snapshots.delete(group_snapshot_id)

View File

@ -37,6 +37,7 @@ MICROVERSION_FEATURES = {
"auto_allocated_network": ["2.37", "2.42"],
},
"cinder": {
"groups": ["3.27", "3.43", "3.48"],
"consistency_groups": ["2.0", "3.10"],
"message_list": ["3.5", "3.29"]
}

View File

@ -12,10 +12,17 @@
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard import api
from openstack_dashboard import policy
LOG = logging.getLogger(__name__)
class CGSnapshots(horizon.Panel):
name = _("Consistency Group Snapshots")
@ -25,3 +32,20 @@ class CGSnapshots(horizon.Panel):
'openstack.services.volumev3'),
)
policy_rules = (("volume", "consistencygroup:get_all_cgsnapshots"),)
def allowed(self, context):
request = context['request']
try:
return (
super(CGSnapshots, self).allowed(context) and
request.user.has_perms(self.permissions) and
policy.check(self.policy_rules, request) and
api.cinder.get_microversion(request, 'consistency_groups') and
not api.cinder.get_microversion(request, 'groups')
)
except Exception:
LOG.error("Call to list enabled services failed. This is likely "
"due to a problem communicating with the Cinder "
"endpoint. Consistency Group Snapshot panel will not be "
"displayed.")
return False

View File

@ -12,10 +12,17 @@
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard import api
from openstack_dashboard import policy
LOG = logging.getLogger(__name__)
class CGroups(horizon.Panel):
name = _("Consistency Groups")
@ -25,3 +32,20 @@ class CGroups(horizon.Panel):
'openstack.services.volumev3'),
)
policy_rules = (("volume", "consistencygroup:get_all"),)
def allowed(self, context):
request = context['request']
try:
return (
super(CGroups, self).allowed(context) and
request.user.has_perms(self.permissions) and
policy.check(self.policy_rules, request) and
api.cinder.get_microversion(request, 'consistency_groups') and
not api.cinder.get_microversion(request, 'groups')
)
except Exception:
LOG.error("Call to list enabled services failed. This is likely "
"due to a problem communicating with the Cinder "
"endpoint. Consistency Group panel will not be "
"displayed.")
return False

View File

@ -0,0 +1,75 @@
# 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.urls 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 CreateGroupForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("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.ThemableSelectWidget(
attrs={'class': 'snapshot-selector'},
data_attrs=('name'),
transform=lambda x: "%s" % (x.name)),
required=False)
def prepare_snapshot_source_field(self, request, vg_snapshot_id):
try:
vg_snapshot = cinder.group_snapshot_get(request, vg_snapshot_id)
self.fields['snapshot_source'].choices = ((vg_snapshot_id,
vg_snapshot),)
except Exception:
exceptions.handle(request,
_('Unable to load the specified snapshot.'))
def __init__(self, request, *args, **kwargs):
super(CreateGroupForm, self).__init__(request, *args, **kwargs)
# populate cgroup_id
vg_snapshot_id = kwargs.get('initial', {}).get('vg_snapshot_id', [])
self.fields['vg_snapshot_id'] = forms.CharField(
widget=forms.HiddenInput(),
initial=vg_snapshot_id)
self.prepare_snapshot_source_field(request, vg_snapshot_id)
def handle(self, request, data):
try:
message = _('Creating group "%s".') % data['name']
group = cinder.group_create_from_source(
request,
data['name'],
group_snapshot_id=data['vg_snapshot_id'],
description=data['description'])
messages.info(request, message)
return group
except Exception:
redirect = reverse("horizon:project:vg_snapshots:index")
msg = (_('Unable to create group "%s" from snapshot.')
% data['name'])
exceptions.handle(request,
msg,
redirect=redirect)

View File

@ -0,0 +1,49 @@
# Copyright 2017 Rackspace, Inc.
#
# 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.
import logging
from django.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard import api
from openstack_dashboard import policy
LOG = logging.getLogger(__name__)
class GroupSnapshots(horizon.Panel):
name = _("Group Snapshots")
slug = 'vg_snapshots'
permissions = (
('openstack.services.volume', 'openstack.services.volumev3'),
)
policy_rules = (("volume", "group:get_all_group_snapshots"),)
def allowed(self, context):
request = context['request']
try:
return (
super(GroupSnapshots, self).allowed(context) and
request.user.has_perms(self.permissions) and
policy.check(self.policy_rules, request) and
api.cinder.get_microversion(request, 'groups')
)
except Exception:
LOG.error("Call to list enabled services failed. This is likely "
"due to a problem communicating with the Cinder "
"endpoint. Volume Group Snapshot panel will not be "
"displayed.")
return False

View File

@ -0,0 +1,141 @@
# 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.urls 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 CreateGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "create_group"
verbose_name = _("Create Group")
url = "horizon:project:vg_snapshots:create_group"
classes = ("ajax-modal",)
policy_rules = (("volume", "group:create"),)
class DeleteGroupSnapshot(policy.PolicyTargetMixin, tables.DeleteAction):
name = "delete_vg_snapshot"
policy_rules = (("volume", "group:delete_group_snapshot"),)
@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.group_snapshot_delete(request, obj_id)
class UpdateRow(tables.Row):
ajax = True
def get_data(self, request, vg_snapshot_id):
vg_snapshot = cinder.group_snapshot_get(request, vg_snapshot_id)
if getattr(vg_snapshot, 'group_id', None):
try:
vg_snapshot._group = cinder.group_get(request,
vg_snapshot.group_id)
except Exception:
exceptions.handle(request, _("Unable to retrieve group"))
vg_snapshot._group = None
return vg_snapshot
class GroupNameColumn(tables.WrappingColumn):
def get_raw_data(self, snapshot):
group = snapshot._group
return group.name_or_id if group else _("-")
def get_link_url(self, snapshot):
group = snapshot._group
if group:
return reverse(self.link, args=(group.id,))
class GroupSnapshotsFilterAction(tables.FilterAction):
def filter(self, table, vg_snapshots, filter_string):
"""Naive case-insensitive search."""
query = filter_string.lower()
return [vg_snapshot for vg_snapshot in vg_snapshots
if query in vg_snapshot.name.lower()]
class GroupSnapshotsTable(tables.DataTable):
STATUS_CHOICES = (
("in-use", True),
("available", True),
("creating", None),
("error", False),
)
STATUS_DISPLAY_CHOICES = (
("available",
pgettext_lazy("Current status of Volume Group Snapshot",
u"Available")),
("in-use",
pgettext_lazy("Current status of Volume Group Snapshot",
u"In-use")),
("error",
pgettext_lazy("Current status of Volume Group Snapshot",
u"Error")),
)
name = tables.Column("name_or_id",
verbose_name=_("Name"),
link="horizon:project:vg_snapshots: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)
group = GroupNameColumn(
"name",
verbose_name=_("Group"),
link="horizon:project:volume_groups:detail")
def get_object_id(self, vg_snapshot):
return vg_snapshot.id
class Meta(object):
name = "volume_vg_snapshots"
verbose_name = _("Group Snapshots")
table_actions = (GroupSnapshotsFilterAction,
DeleteGroupSnapshot)
row_actions = (CreateGroup,
DeleteGroupSnapshot,)
row_class = UpdateRow
status_columns = ("status",)
permissions = [
('openstack.services.volume', 'openstack.services.volumev3')
]

View File

@ -0,0 +1,34 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.urls 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/vg_snapshots/_detail_overview.html"
def get_context_data(self, request):
vg_snapshot = self.tab_group.kwargs['vg_snapshot']
return {"vg_snapshot": vg_snapshot}
def get_redirect_url(self):
return reverse('horizon:project:vg_snapshots:index')
class DetailTabs(tabs.TabGroup):
slug = "vg_snapshots_details"
tabs = (OverviewTab,)

View File

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

View File

@ -0,0 +1,50 @@
{% load i18n sizeformat parse_date %}
<div class="detail">
<dl class="dl-horizontal">
<dt>{% trans "Name" %}</dt>
<dd data-display="{{ vg_snapshot.name|default:vg_snapshot.id }}">{{ vg_snapshot.name }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ vg_snapshot.id }}</dd>
{% if vg_snapshot.description %}
<dt>{% trans "Description" %}</dt>
<dd>{{ vg_snapshot.description }}</dd>
{% endif %}
<dt>{% trans "Status" %}</dt>
<dd>{{ vg_snapshot.status|capfirst }}</dd>
<dt>{% trans "Group" %}</dt>
<dd>
<a href="{% url 'horizon:project:volume_groups:detail' vg_snapshot.group_id %}">
{% if vg_snapshot.vg_name %}
{{ vg_snapshot.vg_name }}
{% else %}
{{ vg_snapshot.group_id }}
{% endif %}
</a>
</dd>
<dt>{% trans "Group Type" %}</dt>
<dd>{{ vg_snapshot.group_type_id }}</dd>
<dt>{% trans "Created" %}</dt>
<dd>{{ vg_snapshot.created_at|parse_isotime }}</dd>
</dl>
<h4>{% trans "Snapshot Volume Types" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_type_names in vg_snapshot.volume_type_names %}
<dd>{{ vol_type_names }}</dd>
{% endfor %}
</dl>
<h4>{% trans "Snapshot Volumes" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_names in vg_snapshot.volume_names %}
<dd>{{ vol_names }}</dd>
{% empty %}
<dd>
<em>{% trans "No assigned volumes" %}</em>
</dd>
{% endfor %}
</dl>
</div>

View File

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

View File

@ -0,0 +1,198 @@
# 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.urls import reverse
import mock
from openstack_dashboard.api import cinder
from openstack_dashboard.test import helpers as test
INDEX_URL = reverse('horizon:project:vg_snapshots:index')
class GroupSnapshotTests(test.TestCase):
@mock.patch.object(cinder, 'group_snapshot_get')
@mock.patch.object(cinder, 'group_create_from_source')
def test_create_group_from_snapshot(self,
mock_group_create_from_source,
mock_group_snapshot_get):
group = self.cinder_groups.first()
vg_snapshot = self.cinder_group_snapshots.first()
formData = {'vg_snapshot_id': vg_snapshot.id,
'name': 'test VG SS Create',
'description': 'test desc'}
mock_group_snapshot_get.return_value = vg_snapshot
mock_group_create_from_source.return_value = group
url = reverse('horizon:project:vg_snapshots:create_group',
args=[vg_snapshot.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(
res, reverse('horizon:project:volume_groups:index'))
mock_group_snapshot_get.assert_called_once_with(
test.IsHttpRequest(), vg_snapshot.id)
mock_group_create_from_source.assert_called_once_with(
test.IsHttpRequest(),
formData['name'],
group_snapshot_id=formData['vg_snapshot_id'],
description=formData['description'])
@mock.patch.object(cinder, 'group_snapshot_get')
@mock.patch.object(cinder, 'group_create_from_source')
def test_create_group_from_snapshot_exception(
self,
mock_group_create_from_source,
mock_group_snapshot_get):
vg_snapshot = self.cinder_group_snapshots.first()
new_cg_name = 'test VG SS Create'
formData = {'vg_snapshot_id': vg_snapshot.id,
'name': new_cg_name,
'description': 'test desc'}
mock_group_snapshot_get.return_value = vg_snapshot
mock_group_create_from_source.side_effect = \
self.exceptions.cinder
url = reverse('horizon:project:vg_snapshots:create_group',
args=[vg_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 group "%s" from snapshot.'
% new_cg_name,
res.cookies.output().replace('\\', ''))
self.assertRedirectsNoFollow(res, INDEX_URL)
mock_group_snapshot_get.assert_called_once_with(
test.IsHttpRequest(), vg_snapshot.id)
mock_group_create_from_source.assert_called_once_with(
test.IsHttpRequest(),
formData['name'],
group_snapshot_id=formData['vg_snapshot_id'],
description=formData['description'])
@mock.patch.object(cinder, 'group_snapshot_list')
@mock.patch.object(cinder, 'group_snapshot_delete')
@mock.patch.object(cinder, 'group_list')
def test_delete_group_snapshot(self,
mock_group_list,
mock_group_snapshot_delete,
mock_group_snapshot_list):
vg_snapshots = self.cinder_group_snapshots.list()
vg_snapshot = self.cinder_group_snapshots.first()
mock_group_snapshot_list.return_value = vg_snapshots
mock_group_snapshot_delete.return_value = None
mock_group_list.return_value = self.cinder_groups.list()
form_data = {'action': 'volume_vg_snapshots__delete_vg_snapshot__%s'
% vg_snapshot.id}
res = self.client.post(INDEX_URL, form_data, follow=True)
self.assertEqual(res.status_code, 200)
self.assertIn("Scheduled deletion of Snapshot: %s" % vg_snapshot.name,
[m.message for m in res.context['messages']])
self.assert_mock_multiple_calls_with_same_arguments(
mock_group_snapshot_list, 2,
mock.call(test.IsHttpRequest()))
mock_group_snapshot_delete.assert_called_once_with(
test.IsHttpRequest(), vg_snapshot.id)
self.assert_mock_multiple_calls_with_same_arguments(
mock_group_list, 2,
mock.call(test.IsHttpRequest()))
@mock.patch.object(cinder, 'group_snapshot_list')
@mock.patch.object(cinder, 'group_snapshot_delete')
@mock.patch.object(cinder, 'group_list')
def test_delete_group_snapshot_exception(self,
mock_group_list,
mock_group_snapshot_delete,
mock_group_snapshot_list):
vg_snapshots = self.cinder_group_snapshots.list()
vg_snapshot = self.cinder_group_snapshots.first()
mock_group_snapshot_list.return_value = vg_snapshots
mock_group_snapshot_delete.side_effect = self.exceptions.cinder
mock_group_list.return_value = self.cinder_groups.list()
form_data = {'action': 'volume_vg_snapshots__delete_vg_snapshot__%s'
% vg_snapshot.id}
res = self.client.post(INDEX_URL, form_data, follow=True)
self.assertEqual(res.status_code, 200)
self.assertIn("Unable to delete snapshot: %s" % vg_snapshot.name,
[m.message for m in res.context['messages']])
self.assert_mock_multiple_calls_with_same_arguments(
mock_group_snapshot_list, 2,
mock.call(test.IsHttpRequest()))
mock_group_snapshot_delete.assert_called_once_with(
test.IsHttpRequest(), vg_snapshot.id)
self.assert_mock_multiple_calls_with_same_arguments(
mock_group_list, 2,
mock.call(test.IsHttpRequest()))
@mock.patch.object(cinder, 'group_snapshot_get')
@mock.patch.object(cinder, 'group_get')
@mock.patch.object(cinder, 'volume_type_get')
@mock.patch.object(cinder, 'volume_list')
def test_detail_view(self,
mock_volume_list,
mock_volume_type_get,
mock_group_get,
mock_group_snapshot_get):
vg_snapshot = self.cinder_group_snapshots.first()
group = self.cinder_groups.first()
volume_type = self.cinder_volume_types.first()
volumes = self.cinder_volumes.list()
mock_group_snapshot_get.return_value = vg_snapshot
mock_group_get.return_value = group
mock_volume_type_get.return_value = volume_type
mock_volume_list.return_value = volumes
url = reverse(
'horizon:project:vg_snapshots:detail',
args=[vg_snapshot.id])
res = self.client.get(url)
self.assertNoFormErrors(res)
self.assertEqual(res.status_code, 200)
mock_group_snapshot_get.assert_called_once_with(
test.IsHttpRequest(), vg_snapshot.id)
mock_group_get.assert_called_once_with(
test.IsHttpRequest(), group.id)
mock_volume_type_get.assert_called_once_with(
test.IsHttpRequest(), volume_type.id)
search_opts = {'group_id': group.id}
mock_volume_list.assert_called_once_with(
test.IsHttpRequest(), search_opts=search_opts)
@mock.patch.object(cinder, 'group_snapshot_get')
def test_detail_view_with_exception(self, mock_group_snapshot_get):
vg_snapshot = self.cinder_group_snapshots.first()
mock_group_snapshot_get.side_effect = self.exceptions.cinder
url = reverse(
'horizon:project:vg_snapshots:detail',
args=[vg_snapshot.id])
res = self.client.get(url)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
mock_group_snapshot_get.assert_called_once_with(
test.IsHttpRequest(), vg_snapshot.id)

View File

@ -0,0 +1,25 @@
# 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 url
from openstack_dashboard.dashboards.project.vg_snapshots import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^(?P<vg_snapshot_id>[^/]+)/detail/$',
views.DetailView.as_view(),
name='detail'),
url(r'^(?P<vg_snapshot_id>[^/]+)/create_group/$',
views.CreateGroupView.as_view(),
name='create_group'),
]

View File

@ -0,0 +1,158 @@
# 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.urls import reverse
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import tables
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.vg_snapshots \
import forms as vg_snapshot_forms
from openstack_dashboard.dashboards.project.vg_snapshots \
import tables as vg_snapshot_tables
from openstack_dashboard.dashboards.project.vg_snapshots \
import tabs as vg_snapshot_tabs
GROUP_INFO_FIELDS = ("name",
"description")
INDEX_URL = "horizon:project:vg_snapshots:index"
class IndexView(tables.DataTableView):
table_class = vg_snapshot_tables.GroupSnapshotsTable
page_title = _("Group Snapshots")
def get_data(self):
try:
vg_snapshots = api.cinder.group_snapshot_list(self.request)
except Exception:
vg_snapshots = []
exceptions.handle(self.request, _("Unable to retrieve "
"volume group snapshots."))
try:
groups = dict((g.id, g) for g
in api.cinder.group_list(self.request))
except Exception:
groups = {}
exceptions.handle(self.request,
_("Unable to retrieve volume groups."))
for gs in vg_snapshots:
gs._group = groups.get(gs.group_id)
return vg_snapshots
class DetailView(tabs.TabView):
tab_group_class = vg_snapshot_tabs.DetailTabs
template_name = 'horizon/common/_detail.html'
page_title = "{{ vg_snapshot.name|default:vg_snapshot.id }}"
def get_context_data(self, **kwargs):
context = super(DetailView, self).get_context_data(**kwargs)
vg_snapshot = self.get_data()
table = vg_snapshot_tables.GroupSnapshotsTable(self.request)
context["vg_snapshot"] = vg_snapshot
context["url"] = self.get_redirect_url()
context["actions"] = table.render_row_actions(vg_snapshot)
return context
@memoized.memoized_method
def get_data(self):
try:
vg_snapshot_id = self.kwargs['vg_snapshot_id']
vg_snapshot = api.cinder.group_snapshot_get(self.request,
vg_snapshot_id)
group_id = vg_snapshot.group_id
group = api.cinder.group_get(self.request, group_id)
vg_snapshot.vg_name = group.name
vg_snapshot.volume_type_names = []
for vol_type_id in group.volume_types:
vol_type = api.cinder.volume_type_get(self.request,
vol_type_id)
vg_snapshot.volume_type_names.append(vol_type.name)
vg_snapshot.volume_names = []
search_opts = {'group_id': group_id}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
for volume in volumes:
vg_snapshot.volume_names.append(volume.name)
except Exception:
redirect = self.get_redirect_url()
exceptions.handle(self.request,
_('Unable to retrieve group snapshot details.'),
redirect=redirect)
return vg_snapshot
@staticmethod
def get_redirect_url():
return reverse(INDEX_URL)
def get_tabs(self, request, *args, **kwargs):
vg_snapshot = self.get_data()
return self.tab_group_class(request, vg_snapshot=vg_snapshot, **kwargs)
class CreateGroupView(forms.ModalFormView):
form_class = vg_snapshot_forms.CreateGroupForm
template_name = 'project/vg_snapshots/create.html'
submit_url = "horizon:project:vg_snapshots:create_group"
success_url = reverse_lazy('horizon:project:volume_groups:index')
page_title = _("Create Volume Group")
def get_context_data(self, **kwargs):
context = super(CreateGroupView, self).get_context_data(**kwargs)
context['vg_snapshot_id'] = self.kwargs['vg_snapshot_id']
args = (self.kwargs['vg_snapshot_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
try:
# get number of volumes we will be creating
vg_snapshot = cinder.group_snapshot_get(
self.request, context['vg_snapshot_id'])
group_id = vg_snapshot.group_id
search_opts = {'group_id': group_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['totalVolumesUsed'] + num_volumes > \
usages['maxTotalVolumes']:
raise ValueError(_('Unable to create 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 group information.'))
return context
def get_initial(self):
return {'vg_snapshot_id': self.kwargs["vg_snapshot_id"]}

View File

@ -0,0 +1,198 @@
# 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.urls 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 clean(self):
cleaned_data = super(UpdateForm, self).clean()
new_desc = cleaned_data.get('description')
old_desc = self.initial['description']
if old_desc and not new_desc:
error_msg = _("Description is required.")
self._errors['description'] = self.error_class([error_msg])
return cleaned_data
return cleaned_data
def handle(self, request, data):
group_id = self.initial['group_id']
try:
cinder.group_update(request, group_id,
data['name'],
data['description'])
message = _('Updating volume group "%s"') % data['name']
messages.info(request, message)
return True
except Exception:
redirect = reverse("horizon:project:volume_groups:index")
exceptions.handle(request,
_('Unable to update volume group.'),
redirect=redirect)
class RemoveVolsForm(forms.SelfHandlingForm):
def handle(self, request, data):
group_id = self.initial['group_id']
name = self.initial['name']
search_opts = {'group_id': group_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)
cinder.group_update(request, group_id,
remove_volumes=assigned_vols)
message = _('Removing volumes from volume group "%s"') % name
messages.info(request, message)
return True
except Exception:
redirect = reverse("horizon:project:volume_groups:index")
exceptions.handle(request,
_('Errors occurred in removing volumes '
'from group.'),
redirect=redirect)
class DeleteForm(forms.SelfHandlingForm):
delete_volumes = forms.BooleanField(label=_("Delete Volumes"),
required=False)
def handle(self, request, data):
group_id = self.initial['group_id']
name = self.initial['name']
delete_volumes = data['delete_volumes']
try:
cinder.group_delete(request, group_id,
delete_volumes=delete_volumes)
message = _('Deleting volume group "%s"') % name
messages.success(request, message)
return True
except Exception:
redirect = reverse("horizon:project:volume_groups:index")
exceptions.handle(request, _('Errors occurred in deleting 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 handle(self, request, data):
group_id = self.initial['group_id']
try:
message = _('Creating group snapshot "%s".') \
% data['name']
snapshot = cinder.group_snapshot_create(request,
group_id,
data['name'],
data['description'])
messages.info(request, message)
return snapshot
except Exception as e:
redirect = reverse("horizon:project:volume_groups:index")
msg = _('Unable to create group snapshot.')
if e.code == 413:
msg = _('Requested snapshot would exceed the allowed quota.')
else:
search_opts = {'group_id': group_id}
volumes = cinder.volume_list(request,
search_opts=search_opts)
if len(volumes) == 0:
msg = _('Unable to create snapshot. '
'group must contain volumes.')
exceptions.handle(request,
msg,
redirect=redirect)
class CloneGroupForm(forms.SelfHandlingForm):
name = forms.CharField(max_length=255, label=_("Group Name"))
description = forms.CharField(max_length=255,
widget=forms.Textarea(attrs={'rows': 4}),
label=_("Description"),
required=False)
group_source = forms.ChoiceField(
label=_("Use a group as source"),
widget=forms.ThemableSelectWidget(
attrs={'class': 'image-selector'},
data_attrs=('name'),
transform=lambda x: "%s" % (x.name)),
required=False)
def prepare_group_source_field(self, request):
try:
group_id = self.initial['group_id']
group = cinder.group_get(request, group_id)
self.fields['group_source'].choices = ((group_id, group),)
except Exception:
exceptions.handle(request,
_('Unable to load the specified group.'))
def __init__(self, request, *args, **kwargs):
super(CloneGroupForm, self).__init__(request, *args, **kwargs)
self.prepare_group_source_field(request)
def handle(self, request, data):
group_id = self.initial['group_id']
try:
message = _('Creating consistency group "%s".') % data['name']
group = cinder.group_create_from_source(
request,
data['name'],
source_group_id=group_id,
description=data['description'])
messages.info(request, message)
return group
except Exception:
redirect = reverse("horizon:project:volume_groups:index")
msg = _('Unable to clone group.')
search_opts = {'group_id': group_id}
volumes = cinder.volume_list(request, search_opts=search_opts)
if len(volumes) == 0:
msg = _('Unable to clone empty group.')
exceptions.handle(request,
msg,
redirect=redirect)

View File

@ -0,0 +1,48 @@
# Copyright 2017 NEC Corporation
#
# 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.
import logging
from django.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard import api
from openstack_dashboard import policy
LOG = logging.getLogger(__name__)
class VolumeGroups(horizon.Panel):
name = _("Groups")
slug = 'volume_groups'
permissions = (
('openstack.services.volume', 'openstack.services.volumev3'),
)
policy_rules = (("volume", "group:get_all"),)
def allowed(self, context):
request = context['request']
try:
return (
super(VolumeGroups, self).allowed(context) and
request.user.has_perms(self.permissions) and
policy.check(self.policy_rules, request) and
api.cinder.get_microversion(request, 'groups')
)
except Exception:
LOG.error("Call to list enabled services failed. This is likely "
"due to a problem communicating with the Cinder "
"endpoint. Volume Group panel will not be displayed.")
return False

View File

@ -0,0 +1,190 @@
# 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 cinderclient import exceptions as cinder_exc
from django.template import defaultfilters as filters
from django.utils.translation import pgettext_lazy
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tables
from openstack_dashboard.api import cinder
from openstack_dashboard import policy
class CreateGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "create"
verbose_name = _("Create Group")
url = "horizon:project:volume_groups:create"
classes = ("ajax-modal",)
icon = "plus"
policy_rules = (("volume", "group:create"),)
class DeleteGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "deletecg"
verbose_name = _("Delete Group")
url = "horizon:project:volume_groups:delete"
classes = ("ajax-modal", "btn-danger")
policy_rules = (("volume", "group:delete"), )
def allowed(self, request, datum=None):
if datum and datum.has_snapshots:
return False
return True
class RemoveAllVolumes(policy.PolicyTargetMixin, tables.LinkAction):
name = "remove_vols"
verbose_name = _("Remove Volumes from Group")
url = "horizon:project:volume_groups:remove_volumes"
classes = ("ajax-modal",)
policy_rules = (("volume", "group:update"), )
class EditGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "edit"
verbose_name = _("Edit Group")
url = "horizon:project:volume_groups:update"
classes = ("ajax-modal",)
policy_rules = (("volume", "group:update"),)
class ManageVolumes(policy.PolicyTargetMixin, tables.LinkAction):
name = "manage"
verbose_name = _("Manage Volumes")
url = "horizon:project:volume_groups:manage"
classes = ("ajax-modal",)
policy_rules = (("volume", "group:update"),)
def allowed(self, request, group=None):
if hasattr(group, 'status'):
return group.status != 'error'
else:
return False
class CreateSnapshot(policy.PolicyTargetMixin, tables.LinkAction):
name = "create_snapshot"
verbose_name = _("Create Snapshot")
url = "horizon:project:volume_groups:create_snapshot"
classes = ("ajax-modal",)
policy_rules = (("volume", "group:create_group_snapshot"),)
def allowed(self, request, group=None):
if hasattr(group, 'status'):
return group.status != 'error'
else:
return False
class CloneGroup(policy.PolicyTargetMixin, tables.LinkAction):
name = "clone_group"
verbose_name = _("Clone Group")
url = "horizon:project:volume_groups:clone_group"
classes = ("ajax-modal",)
policy_rules = (("volume", "group:create"),)
def allowed(self, request, group=None):
if hasattr(group, 'status'):
return group.status != 'error'
else:
return False
class UpdateRow(tables.Row):
ajax = True
def get_data(self, request, group_id):
try:
return cinder.group_get_with_vol_type_names(request, group_id)
except cinder_exc.NotFound:
# NotFound error must be raised to make ajax UpdateRow work.
raise
except Exception:
exceptions.handle(request, _('Unable to display group.'))
class GroupsFilterAction(tables.FilterAction):
def filter(self, table, groups, filter_string):
"""Naive case-insensitive search."""
query = filter_string.lower()
return [group for group in groups
if query in group.name.lower()]
def get_volume_types(group):
vtypes_str = ''
if hasattr(group, 'volume_type_names'):
vtypes_str = ",".join(group.volume_type_names)
return vtypes_str
class GroupsTable(tables.DataTable):
STATUS_CHOICES = (
("in-use", True),
("available", True),
("creating", None),
("error", False),
)
STATUS_DISPLAY_CHOICES = (
("available",
pgettext_lazy("Current status of Volume Group", u"Available")),
("in-use",
pgettext_lazy("Current status of Volume Group", u"In-use")),
("error",
pgettext_lazy("Current status of Volume Group", u"Error")),
)
name = tables.WrappingColumn("name_or_id",
verbose_name=_("Name"),
link="horizon:project:volume_groups: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)"))
has_snapshots = tables.Column("has_snapshots",
verbose_name=_("Has Snapshots"),
filters=(filters.yesno,))
def get_object_id(self, group):
return group.id
class Meta(object):
name = "volume_groups"
verbose_name = _("Volume Groups")
table_actions = (
CreateGroup,
GroupsFilterAction,
)
row_actions = (
CreateSnapshot,
ManageVolumes,
EditGroup,
CloneGroup,
RemoveAllVolumes,
DeleteGroup,
)
row_class = UpdateRow
status_columns = ("status",)
permissions = ['openstack.services.volume']

View File

@ -0,0 +1,34 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.urls 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/volume_groups/_detail_overview.html")
def get_context_data(self, request):
group = self.tab_group.kwargs['group']
return {"group": group}
def get_redirect_url(self):
return reverse('horizon:project:volume_groups:index')
class GroupsDetailTabs(tabs.TabGroup):
slug = "group_details"
tabs = (OverviewTab,)

View File

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

View File

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

View File

@ -0,0 +1,9 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block modal-body-right %}
<p>{% trans "Volume groups can not be deleted if they contain volumes." %}</p>
<p>{% trans "Check the &quot;Delete Volumes&quot; box to also delete any volumes associated with this group." %}</p>
<p>{% trans "Note that a volume can not be deleted if it is &quot;attached&quot; or has any dependent snapshots." %}</p>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% load i18n sizeformat parse_date %}
<div class="detail">
<dl class="dl-horizontal">
<dt>{% trans "Name" %}</dt>
<dd>{{ group.name|default:_("-") }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ group.id }}</dd>
<dt>{% trans "Description" %}</dt>
<dd>{{ group.description|default:_("-") }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>{{ group.status|capfirst }}</dd>
<dt>{% trans "Availability Zone" %}</dt>
<dd>{{ group.availability_zone }}</dd>
<dt>{% trans "Group Type" %}</dt>
<dd>{{ group.group_type }}</dd>
<dt>{% trans "Created" %}</dt>
<dd>{{ group.created_at|parse_isotime }}</dd>
<dt>{% trans "Replication Status" %}</dt>
<dd>{{ group.replication_status }}</dd>
</dl>
<h4>{% trans "Volume Types" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol_type_name in group.volume_type_names %}
<dd>{{ vol_type_name }}</dd>
{% endfor %}
</dl>
<h4>{% trans "Volumes" %}</h4>
<hr class="header_rule">
<dl class="dl-horizontal">
{% for vol in group.volume_names %}
<dd><a href="{% url 'horizon:project:volumes:detail' vol.id %}">{{ vol.name }}</a></dd>
{% empty %}
<dd>
<em>{% trans "No assigned volumes" %}</em>
</dd>
{% endfor %}
</dl>
</div>

View File

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

View File

@ -0,0 +1,42 @@
{% extends "project/volumes/_volume_limits.html" %}
{% load i18n horizon humanize %}
{% block title %}
{% trans "From here you can create a snapshot of a volume." %}
{% endblock %}
{% block head %}
{% trans "Snapshot Limits" %}
{% endblock %}
{% block gigabytes_used %}
{{ usages.totalGigabytesUsed|intcomma }}
{% endblock %}
{% block gigabytes_used_progress %}
"{{ usages.totalGigabytesUsed }}"
{% endblock %}
{% block type_title %}
{% trans "Number of Snapshots" %}
{% endblock %}
{% block used %}
{{ usages.totalSnapshotsUsed|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.totalSnapshotsUsed }}"
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,378 @@
# 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.
import functools
from django.urls import reverse
from django.utils.http import urlunquote
import mock
from openstack_dashboard.api import cinder
from openstack_dashboard.test import helpers as test
INDEX_URL = reverse('horizon:project:volume_groups:index')
VOLUME_GROUPS_SNAP_INDEX_URL = urlunquote(reverse(
'horizon:project:vg_snapshots:index'))
def create_mocks(target, methods):
def wrapper(function):
@functools.wraps(function)
def wrapped(inst, *args, **kwargs):
for method in methods:
if isinstance(method, str):
method_mocked = method
attr_name = method
else:
method_mocked = method[0]
attr_name = method[1]
m = mock.patch.object(target, method_mocked)
setattr(inst, 'mock_%s' % attr_name, m.start())
return function(inst, *args, **kwargs)
return wrapped
return wrapper
class VolumeGroupTests(test.TestCase):
@create_mocks(cinder, [
'extension_supported',
'availability_zone_list',
'volume_type_list',
'group_list',
'group_type_list',
'group_create',
])
def test_create_group(self):
group = self.cinder_groups.first()
volume_types = self.cinder_volume_types.list()
volume_type_id = self.cinder_volume_types.first().id
selected_types = [volume_type_id]
az = self.cinder_availability_zones.first().zoneName
formData = {
'volume_types': '1',
'name': 'test VG',
'description': 'test desc',
'availability_zone': az,
'group_type': group.group_type,
'add_vtypes_to_group_role_member': selected_types,
}
self.mock_extension_supported.return_value = True
self.mock_availability_zone_list.return_value = \
self.cinder_availability_zones.list()
self.mock_volume_type_list.return_value = volume_types
self.mock_group_list.return_value = self.cinder_groups.list()
self.mock_group_type_list.return_value = self.cinder_group_types.list()
self.mock_group_create.return_value = group
url = reverse('horizon:project:volume_groups:create')
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_extension_supported.assert_called_once_with(
test.IsHttpRequest(), 'AvailabilityZones')
self.mock_availability_zone_list.assert_called_once_with(
test.IsHttpRequest())
self.mock_volume_type_list.assert_called_once_with(
test.IsHttpRequest())
self.mock_group_list.assert_called_once_with(test.IsHttpRequest())
self.mock_group_type_list.assert_called_once_with(test.IsHttpRequest())
self.mock_group_create.assert_called_once_with(
test.IsHttpRequest(),
formData['name'],
formData['group_type'],
selected_types,
description=formData['description'],
availability_zone=formData['availability_zone'])
@create_mocks(cinder, [
'extension_supported',
'availability_zone_list',
'volume_type_list',
'group_list',
'group_type_list',
'group_create',
])
def test_create_group_exception(self):
group = self.cinder_groups.first()
volume_types = self.cinder_volume_types.list()
volume_type_id = self.cinder_volume_types.first().id
selected_types = [volume_type_id]
az = self.cinder_availability_zones.first().zoneName
formData = {
'volume_types': '1',
'name': 'test VG',
'description': 'test desc',
'availability_zone': az,
'group_type': group.group_type,
'add_vtypes_to_group_role_member': selected_types,
}
self.mock_extension_supported.return_value = True
self.mock_availability_zone_list.return_value = \
self.cinder_availability_zones.list()
self.mock_volume_type_list.return_value = volume_types
self.mock_group_list.return_value = self.cinder_groups.list()
self.mock_group_type_list.return_value = self.cinder_group_types.list()
self.mock_group_create.side_effect = self.exceptions.cinder
url = reverse('horizon:project:volume_groups:create')
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.assertIn("Unable to create group.",
res.cookies.output())
self.mock_extension_supported.assert_called_once_with(
test.IsHttpRequest(), 'AvailabilityZones')
self.mock_availability_zone_list.assert_called_once_with(
test.IsHttpRequest())
self.mock_volume_type_list.assert_called_once_with(
test.IsHttpRequest())
self.mock_group_list.assert_called_once_with(test.IsHttpRequest())
self.mock_group_type_list.assert_called_once_with(test.IsHttpRequest())
self.mock_group_create.assert_called_once_with(
test.IsHttpRequest(),
formData['name'],
formData['group_type'],
selected_types,
description=formData['description'],
availability_zone=formData['availability_zone'])
@create_mocks(cinder, ['group_get', 'group_delete'])
def test_delete_group(self):
group = self.cinder_groups.first()
self.mock_group_get.return_value = group
self.mock_group_delete.return_value = None
url = reverse('horizon:project:volume_groups:delete',
args=[group.id])
res = self.client.post(url)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_group_get.assert_called_once_with(test.IsHttpRequest(),
group.id)
self.mock_group_delete.assert_called_once_with(test.IsHttpRequest(),
group.id,
delete_volumes=False)
@create_mocks(cinder, ['group_get', 'group_delete'])
def test_delete_group_delete_volumes_flag(self):
group = self.cinder_consistencygroups.first()
formData = {'delete_volumes': True}
self.mock_group_get.return_value = group
self.mock_group_delete.return_value = None
url = reverse('horizon:project:volume_groups:delete',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_group_get.assert_called_once_with(test.IsHttpRequest(),
group.id)
self.mock_group_delete.assert_called_once_with(test.IsHttpRequest(),
group.id,
delete_volumes=True)
@create_mocks(cinder, ['group_get', 'group_delete'])
def test_delete_group_exception(self):
group = self.cinder_groups.first()
formData = {'delete_volumes': False}
self.mock_group_get.return_value = group
self.mock_group_delete.side_effect = self.exceptions.cinder
url = reverse('horizon:project:volume_groups:delete',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_group_get.assert_called_once_with(test.IsHttpRequest(),
group.id)
self.mock_group_delete.assert_called_once_with(test.IsHttpRequest(),
group.id,
delete_volumes=False)
def test_update_group_add_vol(self):
self._test_update_group_add_remove_vol(add=True)
def test_update_group_remove_vol(self):
self._test_update_group_add_remove_vol(add=False)
@create_mocks(cinder, ['volume_list',
'volume_type_list',
'group_get',
'group_update'])
def _test_update_group_add_remove_vol(self, add=True):
group = self.cinder_groups.first()
volume_types = self.cinder_volume_types.list()
volumes = (self.cinder_volumes.list() +
self.cinder_group_volumes.list())
group_voltype_names = [t.name for t in volume_types
if t.id in group.volume_types]
compat_volumes = [v for v in volumes
if v.volume_type in group_voltype_names]
compat_volume_ids = [v.id for v in compat_volumes]
assigned_volume_ids = [v.id for v in compat_volumes
if getattr(v, 'group_id', None)]
add_volume_ids = [v.id for v in compat_volumes
if v.id not in assigned_volume_ids]
new_volums = compat_volume_ids if add else []
formData = {
'default_add_volumes_to_group_role': 'member',
'add_volumes_to_group_role_member': new_volums,
}
self.mock_volume_list.return_value = volumes
self.mock_volume_type_list.return_value = volume_types
self.mock_group_get.return_value = group
self.mock_group_update.return_value = group
url = reverse('horizon:project:volume_groups:manage',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.assert_mock_multiple_calls_with_same_arguments(
self.mock_volume_list, 2,
mock.call(test.IsHttpRequest()))
self.mock_volume_type_list.assert_called_once_with(
test.IsHttpRequest())
self.mock_group_get.assert_called_once_with(
test.IsHttpRequest(), group.id)
if add:
self.mock_group_update.assert_called_once_with(
test.IsHttpRequest(), group.id,
add_volumes=add_volume_ids,
remove_volumes=[])
else:
self.mock_group_update.assert_called_once_with(
test.IsHttpRequest(), group.id,
add_volumes=[],
remove_volumes=assigned_volume_ids)
@create_mocks(cinder, ['group_get', 'group_update'])
def test_update_group_name_and_description(self):
group = self.cinder_groups.first()
formData = {'name': 'test VG-new',
'description': 'test desc-new'}
self.mock_group_get.return_value = group
self.mock_group_update.return_value = group
url = reverse('horizon:project:volume_groups:update',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_group_get.assert_called_once_with(
test.IsHttpRequest(), group.id)
self.mock_group_update.assert_called_once_with(
test.IsHttpRequest(), group.id,
formData['name'],
formData['description'])
@create_mocks(cinder, ['group_get', 'group_update'])
def test_update_group_with_exception(self):
group = self.cinder_groups.first()
formData = {'name': 'test VG-new',
'description': 'test desc-new'}
self.mock_group_get.return_value = group
self.mock_group_update.side_effect = self.exceptions.cinder
url = reverse('horizon:project:volume_groups:update',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_group_get.assert_called_once_with(
test.IsHttpRequest(), group.id)
self.mock_group_update.assert_called_once_with(
test.IsHttpRequest(), group.id,
formData['name'],
formData['description'])
@mock.patch.object(cinder, 'group_get')
def test_detail_view_with_exception(self, mock_group_get):
group = self.cinder_groups.first()
mock_group_get.side_effect = self.exceptions.cinder
url = reverse('horizon:project:volume_groups:detail',
args=[group.id])
res = self.client.get(url)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
mock_group_get.assert_called_once_with(
test.IsHttpRequest(), group.id)
@create_mocks(cinder, ['group_snapshot_create'])
def test_create_snapshot(self):
group = self.cinder_groups.first()
group_snapshot = self.cinder_group_snapshots.first()
formData = {'name': 'test VG Snapshot',
'description': 'test desc'}
self.mock_group_snapshot_create.return_value = group_snapshot
url = reverse('horizon:project:volume_groups:create_snapshot',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, VOLUME_GROUPS_SNAP_INDEX_URL)
self.mock_group_snapshot_create.assert_called_once_with(
test.IsHttpRequest(),
group.id,
formData['name'],
formData['description'])
@create_mocks(cinder, ['group_get',
'group_create_from_source'])
def test_create_clone(self):
group = self.cinder_groups.first()
formData = {
'group_source': group.id,
'name': 'test VG Clone',
'description': 'test desc',
}
self.mock_group_get.return_value = group
self.mock_group_create_from_source.return_value = group
url = reverse('horizon:project:volume_groups:clone_group',
args=[group.id])
res = self.client.post(url, formData)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
self.mock_group_get.assert_called_once_with(
test.IsHttpRequest(), group.id)
self.mock_group_create_from_source.assert_called_once_with(
test.IsHttpRequest(),
formData['name'],
source_group_id=group.id,
description=formData['description'])

View File

@ -0,0 +1,44 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls import url
from openstack_dashboard.dashboards.project.volume_groups import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^(?P<group_id>[^/]+)$',
views.DetailView.as_view(),
name='detail'),
url(r'^create/$',
views.CreateView.as_view(),
name='create'),
url(r'^(?P<group_id>[^/]+)/update/$',
views.UpdateView.as_view(),
name='update'),
url(r'^(?P<group_id>[^/]+)/remove_volumese/$',
views.RemoveVolumesView.as_view(),
name='remove_volumes'),
url(r'^(?P<group_id>[^/]+)/delete/$',
views.DeleteView.as_view(),
name='delete'),
url(r'^(?P<group_id>[^/]+)/manage/$',
views.ManageView.as_view(),
name='manage'),
url(r'^(?P<group_id>[^/]+)/create_snapshot/$',
views.CreateSnapshotView.as_view(),
name='create_snapshot'),
url(r'^(?P<group_id>[^/]+)/clone_group/$',
views.CloneGroupView.as_view(),
name='clone_group'),
]

View File

@ -0,0 +1,312 @@
# 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.urls import reverse
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import tables
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.usage import quotas
from openstack_dashboard.dashboards.project.volume_groups \
import forms as vol_group_forms
from openstack_dashboard.dashboards.project.volume_groups \
import tables as vol_group_tables
from openstack_dashboard.dashboards.project.volume_groups \
import tabs as vol_group_tabs
from openstack_dashboard.dashboards.project.volume_groups \
import workflows as vol_group_workflows
CGROUP_INFO_FIELDS = ("name",
"description")
INDEX_URL = "horizon:project:cgroups:index"
class IndexView(tables.DataTableView):
table_class = vol_group_tables.GroupsTable
page_title = _("Groups")
def get_data(self):
try:
groups = api.cinder.group_list_with_vol_type_names(self.request)
except Exception:
groups = []
exceptions.handle(self.request,
_("Unable to retrieve volume groups."))
if not groups:
return groups
group_snapshots = api.cinder.group_snapshot_list(self.request)
snapshot_groups = {gs.group_id for gs in group_snapshots}
for g in groups:
g.has_snapshots = g.id in snapshot_groups
return groups
class CreateView(workflows.WorkflowView):
workflow_class = vol_group_workflows.CreateGroupWorkflow
template_name = 'project/volume_groups/create.html'
page_title = _("Create Volume Group")
class UpdateView(forms.ModalFormView):
template_name = 'project/volume_groups/update.html'
page_title = _("Edit Group")
form_class = vol_group_forms.UpdateForm
success_url = reverse_lazy('horizon:project:volume_groups:index')
submit_url = "horizon:project:volume_groups:update"
def get_initial(self):
group = self.get_object()
return {'group_id': self.kwargs["group_id"],
'name': group.name,
'description': group.description}
def get_context_data(self, **kwargs):
context = super(UpdateView, self).get_context_data(**kwargs)
context['group_id'] = self.kwargs['group_id']
args = (self.kwargs['group_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_object(self):
group_id = self.kwargs['group_id']
try:
self._object = cinder.group_get(self.request, group_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve group details.'),
redirect=reverse(INDEX_URL))
return self._object
class RemoveVolumesView(forms.ModalFormView):
template_name = 'project/volume_groups/remove_vols.html'
page_title = _("Remove Volumes from Group")
form_class = vol_group_forms.RemoveVolsForm
success_url = reverse_lazy('horizon:project:volume_groups:index')
submit_url = "horizon:project:volume_groups:remove_volumes"
def get_initial(self):
group = self.get_object()
return {'group_id': self.kwargs["group_id"],
'name': group.name}
def get_context_data(self, **kwargs):
context = super(RemoveVolumesView, self).get_context_data(**kwargs)
context['group_id'] = self.kwargs['group_id']
args = (self.kwargs['group_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_object(self):
group_id = self.kwargs['group_id']
try:
self._object = cinder.group_get(self.request, group_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve group details.'),
redirect=reverse(INDEX_URL))
return self._object
class DeleteView(forms.ModalFormView):
template_name = 'project/volume_groups/delete.html'
page_title = _("Delete Group")
form_class = vol_group_forms.DeleteForm
success_url = reverse_lazy('horizon:project:volume_groups:index')
submit_url = "horizon:project:volume_groups:delete"
submit_label = page_title
def get_initial(self):
group = self.get_object()
return {'group_id': self.kwargs["group_id"],
'name': group.name}
def get_context_data(self, **kwargs):
context = super(DeleteView, self).get_context_data(**kwargs)
context['group_id'] = self.kwargs['group_id']
args = (self.kwargs['group_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_object(self):
group_id = self.kwargs['group_id']
try:
self._object = cinder.group_get(self.request, group_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve group details.'),
redirect=reverse(INDEX_URL))
return self._object
class ManageView(workflows.WorkflowView):
workflow_class = vol_group_workflows.UpdateGroupWorkflow
def get_context_data(self, **kwargs):
context = super(ManageView, self).get_context_data(**kwargs)
context['group_id'] = self.kwargs["group_id"]
return context
def _get_object(self, *args, **kwargs):
group_id = self.kwargs['group_id']
try:
group = cinder.group_get(self.request, group_id)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve group details.'),
redirect=reverse(INDEX_URL))
return group
def get_initial(self):
group = self._get_object()
return {'group_id': group.id,
'name': group.name,
'description': group.description,
'vtypes': getattr(group, "volume_types")}
class CreateSnapshotView(forms.ModalFormView):
form_class = vol_group_forms.CreateSnapshotForm
page_title = _("Create Group Snapshot")
template_name = 'project/volume_groups/create_snapshot.html'
submit_label = _("Create Snapshot")
submit_url = "horizon:project:volume_groups:create_snapshot"
success_url = reverse_lazy('horizon:project:vg_snapshots:index')
def get_context_data(self, **kwargs):
context = super(CreateSnapshotView, self).get_context_data(**kwargs)
context['group_id'] = self.kwargs['group_id']
args = (self.kwargs['group_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
try:
# get number of snapshots we will be creating
search_opts = {'group_id': context['group_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['totalSnapshotsUsed'] + 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 group information.'))
return context
def get_initial(self):
return {'group_id': self.kwargs["group_id"]}
class CloneGroupView(forms.ModalFormView):
form_class = vol_group_forms.CloneGroupForm
page_title = _("Clone Group")
template_name = 'project/volume_groups/clone_group.html'
submit_label = _("Clone Group")
submit_url = "horizon:project:volume_groups:clone_group"
success_url = reverse_lazy('horizon:project:volume_groups:index')
def get_context_data(self, **kwargs):
context = super(CloneGroupView, self).get_context_data(**kwargs)
context['group_id'] = self.kwargs['group_id']
args = (self.kwargs['group_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
try:
# get number of volumes we will be creating
group_id = context['group_id']
search_opts = {'group_id': group_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['totalVolumesUsed'] + num_volumes > \
usages['maxTotalVolumes']:
raise ValueError(_('Unable to create 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 group information.'))
return context
def get_initial(self):
return {'group_id': self.kwargs["group_id"]}
class DetailView(tabs.TabView):
tab_group_class = vol_group_tabs.GroupsDetailTabs
template_name = 'horizon/common/_detail.html'
page_title = "{{ group.name|default:group.id }}"
def get_context_data(self, **kwargs):
context = super(DetailView, self).get_context_data(**kwargs)
group = self.get_data()
table = vol_group_tables.GroupsTable(self.request)
context["group"] = group
context["url"] = self.get_redirect_url()
context["actions"] = table.render_row_actions(group)
return context
@memoized.memoized_method
def get_data(self):
try:
group_id = self.kwargs['group_id']
group = api.cinder.group_get_with_vol_type_names(self.request,
group_id)
search_opts = {'group_id': group_id}
volumes = api.cinder.volume_list(self.request,
search_opts=search_opts)
group.volume_names = [{'id': vol.id, 'name': vol.name}
for vol in volumes]
group_snapshots = api.cinder.group_snapshot_list(
self.request, search_opts=search_opts)
group.has_snapshots = bool(group_snapshots)
except Exception:
redirect = self.get_redirect_url()
exceptions.handle(self.request,
_('Unable to retrieve group details.'),
redirect=redirect)
return group
@staticmethod
def get_redirect_url():
return reverse('horizon:project:volume_groups:index')
def get_tabs(self, request, *args, **kwargs):
group = self.get_data()
return self.tab_group_class(request, group=group, **kwargs)

View File

@ -0,0 +1,374 @@
# 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 workflows
from openstack_dashboard import api
from openstack_dashboard.api import cinder
INDEX_URL = "horizon:project:volume_groups: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 AddGroupInfoAction(workflows.Action):
name = forms.CharField(label=_("Name"),
max_length=255)
description = forms.CharField(widget=forms.widgets.Textarea(
attrs={'rows': 4}),
label=_("Description"),
required=False)
group_type = forms.ChoiceField(
label=_("Group Type"),
widget=forms.ThemableSelectWidget())
availability_zone = forms.ChoiceField(
label=_("Availability Zone"),
required=False,
widget=forms.ThemableSelectWidget(
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(AddGroupInfoAction, self).__init__(request,
*args,
**kwargs)
self.fields['availability_zone'].choices = \
availability_zones(request)
try:
group_types = [(t.id, t.name) for t
in api.cinder.group_type_list(request)]
group_types.insert(0, ("", _("Select group type")))
self.fields['group_type'].choices = group_types
except Exception:
exceptions.handle(request, _('Unable to retrieve group types.'))
class Meta(object):
name = _("Group Information")
help_text = _("Volume groups provide a mechanism for "
"creating snapshots of multiple volumes at the same "
"point-in-time to ensure data consistency\n\n"
"A volume group can support more than one volume "
"type, but it can only contain volumes hosted by the "
"same back end.")
slug = "set_group_info"
def clean(self):
cleaned_data = super(AddGroupInfoAction, self).clean()
name = cleaned_data.get('name')
try:
groups = cinder.group_list(self.request)
except Exception:
msg = _('Unable to get group list')
exceptions.check_message(["Connection", "refused"], msg)
raise
if groups is not None and name is not None:
for group in groups:
if group.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 group.')
% formatted_name
)
return cleaned_data
class AddGroupInfoStep(workflows.Step):
action_class = AddGroupInfoAction
contributes = ("availability_zone", "group_type",
"description",
"name")
class AddVolumeTypesToGroupAction(workflows.MembershipAction):
def __init__(self, request, *args, **kwargs):
super(AddVolumeTypesToGroupAction, 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_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_group"
def clean(self):
cleaned_data = super(AddVolumeTypesToGroupAction, self).clean()
volume_types = cleaned_data.get('add_vtypes_to_group_role_member')
if not volume_types:
raise forms.ValidationError(
_('At least one volume type must be assigned '
'to a group.')
)
return cleaned_data
class AddVolTypesToGroupStep(workflows.UpdateMembersStep):
action_class = AddVolumeTypesToGroupAction
help_text = _("Add volume types to this group. "
"Multiple volume types can be added to the same "
"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 AddVolumesToGroupAction(workflows.MembershipAction):
def __init__(self, request, *args, **kwargs):
super(AddVolumesToGroupAction, 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 group
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:
group_id = None
vol_is_available = False
in_this_group = False
if hasattr(volume, 'group_id'):
# this vol already belongs to a group
# only include it here if it belongs to this group
group_id = volume.group_id
if not group_id:
# put this vol in the available list
vol_is_available = True
elif group_id == self.initial['group_id']:
# put this vol in the assigned to group list
vol_is_available = True
in_this_group = True
if vol_is_available:
vol_list.append({'volume_name': volume.name,
'volume_id': volume.id,
'in_group': in_this_group,
'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 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((volume['volume_id'], entry))
if volume['in_group']:
assigned_vols.append(volume['volume_id'])
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_group"
class AddVolumesToGroupStep(workflows.UpdateMembersStep):
action_class = AddVolumesToGroupAction
help_text = _("Add/remove volumes to/from this group. "
"Only volumes associated with the volume type(s) assigned "
"to this 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 = ("group_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 CreateGroupWorkflow(workflows.Workflow):
slug = "create_group"
name = _("Create Group")
finalize_button_name = _("Create Group")
failure_message = _('Unable to create group.')
success_message = _('Created new volume group')
success_url = INDEX_URL
default_steps = (AddGroupInfoStep,
AddVolTypesToGroupStep)
def handle(self, request, context):
try:
self.object = cinder.group_create(
request,
context['name'],
context['group_type'],
context['volume_types'],
description=context['description'],
availability_zone=context['availability_zone'])
except Exception:
exceptions.handle(request, _('Unable to create group.'))
return False
return True
class UpdateGroupWorkflow(workflows.Workflow):
slug = "update_group"
name = _("Add/Remove Group Volumes")
finalize_button_name = _("Submit")
success_message = _('Updated volumes for group.')
failure_message = _('Unable to update volumes for group')
success_url = INDEX_URL
default_steps = (AddVolumesToGroupStep,)
def handle(self, request, context):
group_id = context['group_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 selection == volume.id:
selected = True
break
if selected:
# ensure this volume is in this consistency group
if hasattr(volume, 'group_id'):
if volume.group_id != group_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, 'group_id'):
if volume.group_id == group_id:
# remove from this group
remove_vols.append(volume.id)
if not add_vols and not remove_vols:
# nothing to change
return True
cinder.group_update(request, group_id,
add_volumes=add_vols,
remove_volumes=remove_vols)
except Exception:
# error message supplied by form
return False
return True

View File

@ -0,0 +1,9 @@
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'volume_groups'
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'project'
# The slug of the panel group the PANEL is associated with.
PANEL_GROUP = 'volumes'
# Python panel class of the PANEL to be added.
ADD_PANEL = 'openstack_dashboard.dashboards.project.volume_groups.panel.VolumeGroups'

View File

@ -0,0 +1,9 @@
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'vg_snapshots'
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'project'
# The slug of the panel group the PANEL is associated with.
PANEL_GROUP = 'volumes'
# Python panel class of the PANEL to be added.
ADD_PANEL = 'openstack_dashboard.dashboards.project.vg_snapshots.panel.GroupSnapshots'

View File

@ -280,11 +280,26 @@ TEST_GLOBAL_MOCKS_ON_PANELS = {
'.aggregates.panel.Aggregates.can_access'),
'return_value': True,
},
'cgroups': {
'method': ('openstack_dashboard.dashboards.project'
'.cgroups.panel.CGroups.allowed'),
'return_value': True,
},
'cg_snapshots': {
'method': ('openstack_dashboard.dashboards.project'
'.cg_snapshots.panel.CGSnapshots.allowed'),
'return_value': True,
},
'domains': {
'method': ('openstack_dashboard.dashboards.identity'
'.domains.panel.Domains.can_access'),
'return_value': True,
},
'qos': {
'method': ('openstack_dashboard.dashboards.project'
'.network_qos.panel.NetworkQoS.can_access'),
'return_value': True,
},
'server_groups': {
'method': ('openstack_dashboard.dashboards.project'
'.server_groups.panel.ServerGroups.can_access'),
@ -300,9 +315,14 @@ TEST_GLOBAL_MOCKS_ON_PANELS = {
'.trunks.panel.Trunks.can_access'),
'return_value': True,
},
'qos': {
'volume_groups': {
'method': ('openstack_dashboard.dashboards.project'
'.network_qos.panel.NetworkQoS.can_access'),
'.volume_groups.panel.VolumeGroups.allowed'),
'return_value': True,
},
'vg_snapshots': {
'method': ('openstack_dashboard.dashboards.project'
'.vg_snapshots.panel.GroupSnapshots.allowed'),
'return_value': True,
},
'application_credentials': {

View File

@ -27,11 +27,14 @@ from cinderclient.v2 import volume_transfers
from cinderclient.v2 import volume_type_access
from cinderclient.v2 import volume_types
from cinderclient.v2 import volumes
from cinderclient.v3 import group_snapshots
from cinderclient.v3 import group_types
from cinderclient.v3 import groups
from openstack_dashboard import api
from openstack_dashboard.usage import quotas as usage_quotas
from openstack_dashboard.api import cinder as cinder_api
from openstack_dashboard.test.test_data import utils
from openstack_dashboard.usage import quotas as usage_quotas
def data(TEST):
@ -55,6 +58,10 @@ def data(TEST):
TEST.cinder_consistencygroups = utils.TestDataContainer()
TEST.cinder_cgroup_volumes = utils.TestDataContainer()
TEST.cinder_cg_snapshots = utils.TestDataContainer()
TEST.cinder_groups = utils.TestDataContainer()
TEST.cinder_group_types = utils.TestDataContainer()
TEST.cinder_group_snapshots = utils.TestDataContainer()
TEST.cinder_group_volumes = utils.TestDataContainer()
# Services
service_1 = services.Service(services.ServiceManager(None), {
@ -150,22 +157,24 @@ def data(TEST):
TEST.cinder_bootable_volumes.add(api.cinder.Volume(non_bootable_volume))
vol_type1 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
{'id': u'1',
'name': u'vol_type_1',
'description': 'type 1 description',
'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',
'description': 'type 2 description'})
vol_type3 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
{'id': u'3',
'name': u'vol_type_3',
'is_public': False,
'description': 'type 3 description'})
vol_type1 = volume_types.VolumeType(
volume_types.VolumeTypeManager(None),
{'id': u'1',
'name': u'vol_type_1',
'description': 'type 1 description',
'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',
'description': 'type 2 description'})
vol_type3 = volume_types.VolumeType(
volume_types.VolumeTypeManager(None),
{'id': u'3',
'name': u'vol_type_3',
'is_public': False,
'description': 'type 3 description'})
TEST.cinder_volume_types.add(vol_type1, vol_type2, vol_type3)
vol_type_access1 = volume_type_access.VolumeTypeAccess(
volume_type_access.VolumeTypeAccessManager(None),
@ -488,3 +497,72 @@ def data(TEST):
'description': 'cg_ss 1 description',
'consistencygroup_id': u'1'})
TEST.cinder_cg_snapshots.add(cg_snapshot_1)
group_type_1 = group_types.GroupType(
group_types.GroupTypeManager(None),
{
"is_public": True,
"group_specs": {},
"id": "4645cbf7-8aa6-4d42-a5f7-24e6ebe5ba79",
"name": "group-type-1",
"description": None,
})
TEST.cinder_group_types.add(group_type_1)
group_1 = groups.Group(
groups.GroupManager(None),
{
"availability_zone": "nova",
"created_at": "2018-01-09T07:27:22.000000",
"description": "description for group1",
"group_snapshot_id": None,
"group_type": group_type_1.id,
"id": "f64646ac-9bf7-483f-bd85-96c34050a528",
"name": "group1",
"replication_status": "disabled",
"source_group_id": None,
"status": "available",
"volume_types": [
vol_type1.id,
]
})
TEST.cinder_groups.add(cinder_api.Group(group_1))
group_snapshot_1 = group_snapshots.GroupSnapshot(
group_snapshots.GroupSnapshotManager(None),
{
"created_at": "2018-01-09T07:46:03.000000",
"description": "",
"group_id": group_1.id,
"group_type_id": group_type_1.id,
"id": "1036d913-9cb8-46a1-9f56-2f99dc1f14ed",
"name": "group-snap1",
"status": "available",
})
TEST.cinder_group_snapshots.add(group_snapshot_1)
group_volume_1 = volumes.Volume(
volumes.VolumeManager(None),
{'id': "fe9a2664-0f49-4354-bab6-11b2ad352630",
'status': 'available',
'size': 2,
'name': 'group1-volume1',
'display_description': 'Volume 1 in Group 1',
'created_at': '2014-01-27 10:30:00',
'volume_type': 'vol_type_1',
'group_id': group_1.id,
'attachments': []})
group_volume_2 = volumes.Volume(
volumes.VolumeManager(None),
{'id': "a7fb0402-88dc-45a3-970c-d732da63466e",
'status': 'available',
'size': 1,
'name': 'group1-volume2',
'display_description': 'Volume 2 in Group 1',
'created_at': '2014-01-30 10:31:00',
'volume_type': 'vol_type_1',
'group_id': group_1.id,
'attachments': []})
TEST.cinder_group_volumes.add(group_volume_1)
TEST.cinder_group_volumes.add(group_volume_2)

View File

@ -24,16 +24,27 @@ from openstack_dashboard.test import helpers as test
class CinderApiTests(test.APIMockTestCase):
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list(self, mock_cinderclient):
def _stub_cinderclient_with_generic_group(self):
p = mock.patch.object(api.cinder,
'_cinderclient_with_generic_groups').start()
return p.return_value
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list(self):
search_opts = {'all_tenants': 1}
detailed = True
volumes = self.cinder_volumes.list()
volume_transfers = self.cinder_volume_transfers.list()
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = volumes
transfers_mock = cinderclient.transfers.list
@ -47,15 +58,21 @@ class CinderApiTests(test.APIMockTestCase):
search_opts=search_opts)
self.assertEqual(len(volumes), len(api_volumes))
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list_paged(self, mock_cinderclient):
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list_paged(self):
search_opts = {'all_tenants': 1}
detailed = True
volumes = self.cinder_volumes.list()
volume_transfers = self.cinder_volume_transfers.list()
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = volumes
transfers_mock = cinderclient.transfers.list
@ -73,8 +90,13 @@ class CinderApiTests(test.APIMockTestCase):
@override_settings(API_RESULT_PAGE_SIZE=2)
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list_paginate_first_page(self, mock_cinderclient):
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list_paginate_first_page(self):
api.cinder.VERSIONS._active = None
page_size = settings.API_RESULT_PAGE_SIZE
volumes = self.cinder_volumes.list()
@ -84,9 +106,10 @@ class CinderApiTests(test.APIMockTestCase):
mock_volumes = volumes[:page_size + 1]
expected_volumes = mock_volumes[:-1]
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = mock_volumes
transfers_mock = cinderclient.transfers.list
@ -107,8 +130,13 @@ class CinderApiTests(test.APIMockTestCase):
@override_settings(API_RESULT_PAGE_SIZE=2)
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list_paginate_second_page(self, mock_cinderclient):
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list_paginate_second_page(self):
api.cinder.VERSIONS._active = None
page_size = settings.API_RESULT_PAGE_SIZE
volumes = self.cinder_volumes.list()
@ -119,9 +147,10 @@ class CinderApiTests(test.APIMockTestCase):
expected_volumes = mock_volumes[:-1]
marker = expected_volumes[0].id
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = mock_volumes
transfers_mock = cinderclient.transfers.list
@ -143,8 +172,13 @@ class CinderApiTests(test.APIMockTestCase):
@override_settings(API_RESULT_PAGE_SIZE=2)
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list_paginate_last_page(self, mock_cinderclient):
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list_paginate_last_page(self):
api.cinder.VERSIONS._active = None
page_size = settings.API_RESULT_PAGE_SIZE
volumes = self.cinder_volumes.list()
@ -155,9 +189,10 @@ class CinderApiTests(test.APIMockTestCase):
expected_volumes = mock_volumes
marker = expected_volumes[0].id
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = mock_volumes
transfers_mock = cinderclient.transfers.list
@ -179,8 +214,13 @@ class CinderApiTests(test.APIMockTestCase):
@override_settings(API_RESULT_PAGE_SIZE=2)
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list_paginate_back_from_some_page(self, mock_cinderclient):
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list_paginate_back_from_some_page(self):
api.cinder.VERSIONS._active = None
page_size = settings.API_RESULT_PAGE_SIZE
volumes = self.cinder_volumes.list()
@ -191,9 +231,10 @@ class CinderApiTests(test.APIMockTestCase):
expected_volumes = mock_volumes[:-1]
marker = expected_volumes[0].id
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = mock_volumes
transfers_mock = cinderclient.transfers.list
@ -215,8 +256,13 @@ class CinderApiTests(test.APIMockTestCase):
@override_settings(API_RESULT_PAGE_SIZE=2)
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
@mock.patch.object(api.cinder, 'cinderclient')
def test_volume_list_paginate_back_to_first_page(self, mock_cinderclient):
@test.create_mocks({
api.cinder: [
'cinderclient',
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
]
})
def test_volume_list_paginate_back_to_first_page(self):
api.cinder.VERSIONS._active = None
page_size = settings.API_RESULT_PAGE_SIZE
volumes = self.cinder_volumes.list()
@ -227,9 +273,10 @@ class CinderApiTests(test.APIMockTestCase):
expected_volumes = mock_volumes
marker = expected_volumes[0].id
cinderclient = mock_cinderclient.return_value
cinderclient = self.mock_cinderclient.return_value
cinderclient_with_group = self.mock_cinderclient_groups.return_value
volumes_mock = cinderclient.volumes.list
volumes_mock = cinderclient_with_group.volumes.list
volumes_mock.return_value = mock_volumes
transfers_mock = cinderclient.transfers.list