Implement Manage Attachments in Instances

Allow users in Project view/tables the option to attach
existing volumes to an instance.
This allows user to be able to attach existing volumes without
having to switch tabs or views from instance to volume.
Allowing for greater ease of access.

Co-Authored-By: Eric Peterson <eric.peterson1@twcable.com>

Closes Bug: #1518459
Partially Implements Blueprint: manage-volume-instance-views

Change-Id: I9daf508b181aaaf2ac003639f8ab9729442e201b
This commit is contained in:
Ankur Gupta 2016-04-13 15:22:14 -05:00
parent 1554e281bc
commit f75db42662
9 changed files with 343 additions and 4 deletions

View File

@ -168,6 +168,122 @@ class DecryptPasswordInstanceForm(forms.SelfHandlingForm):
return True
class AttachVolume(forms.SelfHandlingForm):
volume = forms.ChoiceField(label=_("Volume ID"),
help_text=_("Select a volume to attach "
"to this instance."))
device = forms.CharField(label=_("Device Name"),
widget=forms.HiddenInput(),
required=False,
help_text=_("Actual device name may differ due "
"to hypervisor settings. If not "
"specified, then hypervisor will "
"select a device name."))
instance_id = forms.CharField(widget=forms.HiddenInput(),
required=True)
def __init__(self, *args, **kwargs):
super(AttachVolume, self).__init__(*args, **kwargs)
# Populate volume choices
volume_list = kwargs.get('initial', {}).get("volume_list", [])
volumes = []
for volume in volume_list:
# Only show volumes that aren't attached to an instance already
if not volume.attachments:
volumes.append(
(volume.id, '%(name)s (%(id)s)'
% {"name": volume.name, "id": volume.id}))
if volumes:
volumes.insert(0, ("", _("Select a volume")))
else:
volumes.insert(0, ("", _("No volumes available")))
self.fields['volume'].choices = volumes
def handle(self, request, data):
instance_id = self.initial.get("instance_id", None)
volume_choices = dict(self.fields['volume'].choices)
volume = volume_choices.get(data['volume'],
_("Unknown volume (None)"))
volume_id = data.get('volume')
device = data.get('device') or None
try:
attach = api.nova.instance_volume_attach(request,
volume_id,
instance_id,
device)
message = _('Attaching volume %(vol)s to instance '
'%(inst)s on %(dev)s.') % {"vol": volume,
"inst": instance_id,
"dev": attach.device}
messages.info(request, message)
except Exception:
redirect = reverse('horizon:project:instances:index')
exceptions.handle(request,
_('Unable to attach volume.'),
redirect=redirect)
return True
class DetachVolume(forms.SelfHandlingForm):
volume = forms.ChoiceField(label=_("Volume ID"),
help_text=_("Select a volume to detach "
"from this instance."))
instance_id = forms.CharField(widget=forms.HiddenInput(),
required=True)
def __init__(self, *args, **kwargs):
super(DetachVolume, self).__init__(*args, **kwargs)
# Populate instance id
instance_id = kwargs.get('initial', {}).get("instance_id", None)
# Populate attached volumes
try:
volumes = []
volume_list = api.nova.instance_volumes_list(self.request,
instance_id)
for volume in volume_list:
volumes.append((volume.id, '%s (%s)' % (volume.name,
volume.id)))
if volume_list:
volumes.insert(0, ("", _("Select a volume")))
else:
volumes.insert(0, ("", _("No volumes attached")))
self.fields['volume'].choices = volumes
except Exception:
redirect = reverse('horizon:project:instances:index')
exceptions.handle(self.request, _("Unable to detach volume."),
redirect=redirect)
def handle(self, request, data):
instance_id = self.initial.get("instance_id", None)
volume_choices = dict(self.fields['volume'].choices)
volume = volume_choices.get(data['volume'],
_("Unknown volume (None)"))
volume_id = data.get('volume')
try:
api.nova.instance_volume_detach(request,
instance_id,
volume_id)
message = _('Detaching volume %(vol)s from instance '
'%(inst)s.') % {"vol": volume,
"inst": instance_id}
messages.info(request, message)
except Exception:
redirect = reverse('horizon:project:instances:index')
exceptions.handle(request,
_("Unable to detach volume."),
redirect=redirect)
return True
class AttachInterface(forms.SelfHandlingForm):
instance_id = forms.CharField(widget=forms.HiddenInput())
network = forms.ChoiceField(label=_("Network"))

View File

@ -920,6 +920,33 @@ class UnlockInstance(policy.PolicyTargetMixin, tables.BatchAction):
api.nova.server_unlock(request, obj_id)
class AttachVolume(tables.LinkAction):
name = "attach_volume"
verbose_name = _("Attach Volume")
url = "horizon:project:instances:attach_volume"
classes = ("ajax-modal",)
policy_rules = (("compute", "compute:attach_volume"),)
# This action should be disabled if the instance
# is not active, or the instance is being deleted
def allowed(self, request, instance=None):
return instance.status in ("ACTIVE") \
and not is_deleting(instance)
class DetachVolume(AttachVolume):
name = "detach_volume"
verbose_name = _("Detach Volume")
url = "horizon:project:instances:detach_volume"
policy_rules = (("compute", "compute:detach_volume"),)
# This action should be disabled if the instance
# is not active, or the instance is being deleted
def allowed(self, request, instance=None):
return instance.status in ("ACTIVE") \
and not is_deleting(instance)
class AttachInterface(policy.PolicyTargetMixin, tables.LinkAction):
name = "attach_interface"
verbose_name = _("Attach Interface")
@ -1220,9 +1247,10 @@ class InstancesTable(tables.DataTable):
row_actions = (StartInstance, ConfirmResize, RevertResize,
CreateSnapshot, SimpleAssociateIP, AssociateIP,
SimpleDisassociateIP, AttachInterface,
DetachInterface, EditInstance, UpdateMetadata,
DecryptInstancePassword, EditInstanceSecurityGroups,
ConsoleLink, LogLink, TogglePause, ToggleSuspend,
ToggleShelve, ResizeLink, LockInstance, UnlockInstance,
DetachInterface, EditInstance, AttachVolume,
DetachVolume, UpdateMetadata, DecryptInstancePassword,
EditInstanceSecurityGroups, ConsoleLink, LogLink,
TogglePause, ToggleSuspend, ToggleShelve,
ResizeLink, LockInstance, UnlockInstance,
SoftRebootInstance, RebootInstance,
StopInstance, RebuildInstance, DeleteInstance)

View File

@ -0,0 +1,7 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Attach Volume to Running Instance." %}</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Detach Volume from Running Instance." %}</p>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Manage Volume" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Attach a Volume") %}
{% endblock page_header %}
{% block main %}
{% include 'project/instances/_attach_volume.html' %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Manage Volume" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Detach a Volume") %}
{% endblock page_header %}
{% block main %}
{% include 'project/instances/_detach_volume.html' %}
{% endblock %}

View File

@ -5128,6 +5128,92 @@ class ConsoleManagerTests(helpers.TestCase):
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@helpers.create_stubs({api.cinder: ('volume_list',),
api.nova: ('server_get',)})
def test_volume_attach_get(self):
server = self.servers.first()
api.cinder.volume_list(IsA(http.HttpRequest))\
.AndReturn(self.cinder_volumes.list())
self.mox.ReplayAll()
url = reverse('horizon:project:instances:attach_volume',
args=[server.id])
res = self.client.get(url)
form = res.context['form']
self.assertEqual(res.status_code, 200)
self.assertFalse(form.fields['device'].required)
self.assertIsInstance(form.fields['volume'].widget,
forms.Select)
self.assertTemplateUsed(res,
'project/instances/attach_volume.html')
@helpers.create_stubs({api.nova: ('instance_volume_attach', 'server_get'),
api.cinder: ('volume_list',)})
def test_volume_attach_post(self):
server = self.servers.first()
volume = api.cinder.volume_list(IsA(http.HttpRequest))\
.AndReturn(self.cinder_volumes.list())
self.mox.ReplayAll()
form_data = {"volume": volume[1].id,
"instance_id": server.id,
"device": None}
url = reverse('horizon:project:instances:attach_volume',
args=[server.id])
res = self.client.post(url, form_data)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@helpers.create_stubs({api.nova: ('server_get', 'instance_volumes_list')})
def test_volume_detach_get(self):
server = self.servers.first()
api.nova.instance_volumes_list(IsA(http.HttpRequest),
server.id)\
.AndReturn(self.cinder_volumes.list())
self.mox.ReplayAll()
url = reverse('horizon:project:instances:detach_volume',
args=[server.id])
res = self.client.get(url)
form = res.context['form']
self.assertIsInstance(form.fields['volume'].widget,
forms.Select)
self.assertEqual(res.status_code, 200)
self.assertTemplateUsed(res,
'project/instances/detach_volume.html')
@helpers.create_stubs({api.nova: ('server_get', 'instance_volumes_list',
'instance_volume_detach')})
def test_volume_detach_post(self):
server = self.servers.first()
volume = api.nova.instance_volumes_list(IsA(http.HttpRequest),
server.id) \
.AndReturn(self.cinder_volumes.list())
self.mox.ReplayAll()
form_data = {"volume": volume[1].id,
"instance_id": server.id}
url = reverse('horizon:project:instances:detach_volume',
args=[server.id])
res = self.client.post(url, form_data)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@helpers.create_stubs({api.neutron: ('port_list',)})
def test_interface_detach_get(self):
server = self.servers.first()

View File

@ -44,4 +44,12 @@ urlpatterns = [
views.AttachInterfaceView.as_view(), name='attach_interface'),
url(INSTANCES % 'detach_interface',
views.DetachInterfaceView.as_view(), name='detach_interface'),
url(r'^(?P<instance_id>[^/]+)/attach_volume/$',
views.AttachVolumeView.as_view(),
name='attach_volume'
),
url(r'^(?P<instance_id>[^/]+)/detach_volume/$',
views.DetachVolumeView.as_view(),
name='detach_volume'
)
]

View File

@ -457,6 +457,71 @@ class AttachInterfaceView(forms.ModalFormView):
return {'instance_id': self.kwargs['instance_id']}
class AttachVolumeView(forms.ModalFormView):
form_class = project_forms.AttachVolume
template_name = 'project/instances/attach_volume.html'
modal_header = _("Attach Volume")
modal_id = "attach_volume_modal"
submit_label = _("Attach Volume")
success_url = reverse_lazy('horizon:project:instances:index')
@memoized.memoized_method
def get_object(self):
try:
return api.nova.server_get(self.request,
self.kwargs["instance_id"])
except Exception:
exceptions.handle(self.request,
_("Unable to retrieve instance."))
def get_initial(self):
args = {'instance_id': self.kwargs['instance_id']}
submit_url = "horizon:project:instances:attach_volume"
self.submit_url = reverse(submit_url, kwargs=args)
try:
volume_list = api.cinder.volume_list(self.request)
except Exception:
volume_list = []
exceptions.handle(self.request,
_("Unable to retrieve volume information."))
return {"instance_id": self.kwargs["instance_id"],
"volume_list": volume_list}
def get_context_data(self, **kwargs):
context = super(AttachVolumeView, self).get_context_data(**kwargs)
context['instance_id'] = self.kwargs['instance_id']
return context
class DetachVolumeView(forms.ModalFormView):
form_class = project_forms.DetachVolume
template_name = 'project/instances/detach_volume.html'
modal_header = _("Detach Volume")
modal_id = "detach_volume_modal"
submit_label = _("Detach Volume")
success_url = reverse_lazy('horizon:project:instances:index')
@memoized.memoized_method
def get_object(self):
try:
return api.nova.server_get(self.request,
self.kwargs['instance_id'])
except Exception:
exceptions.handle(self.request,
_("Unable to retrieve instance."))
def get_initial(self):
args = {'instance_id': self.kwargs['instance_id']}
submit_url = "horizon:project:instances:detach_volume"
self.submit_url = reverse(submit_url, kwargs=args)
return {"instance_id": self.kwargs["instance_id"]}
def get_context_data(self, **kwargs):
context = super(DetachVolumeView, self).get_context_data(**kwargs)
context['instance_id'] = self.kwargs['instance_id']
return context
class DetachInterfaceView(forms.ModalFormView):
form_class = project_forms.DetachInterface
template_name = 'project/instances/detach_interface.html'