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:
parent
1554e281bc
commit
f75db42662
@ -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"))
|
||||
|
@ -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)
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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()
|
||||
|
@ -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'
|
||||
)
|
||||
]
|
||||
|
@ -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'
|
||||
|
Loading…
Reference in New Issue
Block a user