Need ability to evacuate host in syspanel

Implement host evacuate in the hypervisors panel.
An extra tab was added to show in the first one hypervisors
and on the second one compute host. on each compute host
that is down an evacuate host button was added.
If the user press the button a modal windows is shown to
request the needed data to perform the evacuation.

blueprint evacuate-host

Co-Authored-By: Leandro Costantino <leandro.i.costantino@intel.com>
Co-Authored-By: David Lyle <david.lyle@hp.com>

Change-Id: I57a16f99fddd84c287429085c7e90beb59a17aa3
This commit is contained in:
Juan Manuel Olle 2014-02-24 14:37:32 -03:00 committed by David Lyle
parent c268f36350
commit a286e558c6
16 changed files with 524 additions and 9 deletions

View File

@ -120,6 +120,24 @@ class Server(base.APIResourceWrapper):
return getattr(self, 'OS-EXT-AZ:availability_zone', "")
class Hypervisor(base.APIDictWrapper):
"""Simple wrapper around novaclient.hypervisors.Hypervisor."""
_attrs = ['manager', '_loaded', '_info', 'hypervisor_hostname', 'id',
'servers']
@property
def servers(self):
# if hypervisor doesn't have servers, the attribute is not present
servers = []
try:
servers = self._apidict.servers
except Exception:
pass
return servers
class NovaUsage(base.APIResourceWrapper):
"""Simple wrapper around contrib/simple_usage.py."""
@ -707,6 +725,32 @@ def hypervisor_search(request, query, servers=True):
return novaclient(request).hypervisors.search(query, servers)
def evacuate_host(request, host, target=None, on_shared_storage=False):
# TODO(jmolle) This should be change for nova atomic api host_evacuate
hypervisors = novaclient(request).hypervisors.search(host, True)
response = []
err_code = None
for hypervisor in hypervisors:
hyper = Hypervisor(hypervisor)
# if hypervisor doesn't have servers, the attribute is not present
for server in hyper.servers:
try:
novaclient(request).servers.evacuate(server['uuid'],
target,
on_shared_storage)
except nova_exceptions.ClientException as err:
err_code = err.code
msg = _("Name: %(name)s ID: %(uuid)s")
msg = msg % {'name': server['name'], 'uuid': server['uuid']}
response.append(msg)
if err_code:
msg = _('Failed to evacuate instances: %s') % ', '.join(response)
raise nova_exceptions.ClientException(err_code, msg)
return True
def tenant_absolute_limits(request, reserved=False):
limits = novaclient(request).limits.get(reserved=reserved).absolute
limits_dict = {}
@ -723,8 +767,8 @@ def availability_zone_list(request, detailed=False):
return novaclient(request).availability_zones.list(detailed=detailed)
def service_list(request):
return novaclient(request).services.list()
def service_list(request, binary=None):
return novaclient(request).services.list(binary=binary)
def aggregate_details_list(request):

View File

@ -0,0 +1,68 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import messages
from openstack_dashboard import api
class EvacuateHostForm(forms.SelfHandlingForm):
current_host = forms.CharField(label=_("Current Host"),
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
target_host = forms.ChoiceField(label=_("Target Host"),
help_text=_("Choose a Host to evacuate servers to."))
on_shared_storage = forms.BooleanField(label=_("Shared Storage"),
initial=False, required=False)
def __init__(self, request, *args, **kwargs):
super(EvacuateHostForm, self).__init__(request, *args, **kwargs)
initial = kwargs.get('initial', {})
self.fields['target_host'].choices = \
self.populate_host_choices(request, initial)
def populate_host_choices(self, request, initial):
hosts = initial.get('hosts')
current_host = initial.get('current_host')
host_list = sorted([(host, host)
for host in hosts
if host != current_host])
if host_list:
host_list.insert(0, ("", _("Select a target host")))
else:
host_list.insert(0, ("", _("No other hosts available.")))
return host_list
def handle(self, request, data):
try:
current_host = data['current_host']
target_host = data['target_host']
on_shared_storage = data['on_shared_storage']
api.nova.evacuate_host(request, current_host,
target_host, on_shared_storage)
msg = _('Starting evacuation from %(current)s to %(target)s.') % \
{'current': current_host, 'target': target_host}
messages.success(request, msg)
return True
except Exception:
redirect = reverse('horizon:admin:hypervisors:index')
msg = _('Failed to evacuate host: %s.') % data['current_host']
exceptions.handle(request, message=msg, redirect=redirect)
return False

View File

@ -0,0 +1,69 @@
# 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.template import defaultfilters as filters
from django.utils.translation import ugettext_lazy as _
from horizon import tables
from horizon.utils import filters as utils_filters
from openstack_dashboard import api
class EvacuateHost(tables.LinkAction):
name = "evacuate"
data_type_singular = _("Host")
data_type_plural = _("Hosts")
verbose_name = _("Evacuate Host")
url = "horizon:admin:hypervisors:compute:evacuate_host"
classes = ("ajax-modal", "btn-migrate")
policy_rules = (("compute", "compute_extension:evacuate"),)
def __init__(self, **kwargs):
super(EvacuateHost, self).__init__(**kwargs)
self.name = kwargs.get('name', self.name)
self.action_present = kwargs.get('action_present', _("Evacuate"))
self.action_past = kwargs.get('action_past', _("Evacuated"))
def allowed(self, request, instance):
if not api.nova.extension_supported('AdminActions', request):
return False
return self.datum.state == "down"
class ComputeHostFilterAction(tables.FilterAction):
def filter(self, table, services, filter_string):
q = filter_string.lower()
return filter(lambda service: q in service.type.lower(), services)
class ComputeHostTable(tables.DataTable):
host = tables.Column('host', verbose_name=_('Host'))
zone = tables.Column('zone', verbose_name=_('Zone'))
status = tables.Column('status', verbose_name=_('Status'))
state = tables.Column('state', verbose_name=_('State'))
updated_at = tables.Column('updated_at',
verbose_name=_('Updated At'),
filters=(utils_filters.parse_isotime,
filters.timesince))
def get_object_id(self, obj):
return obj.host
class Meta:
name = "compute_host"
verbose_name = _("Compute Host")
table_actions = (ComputeHostFilterAction,)
multi_select = False
row_actions = (EvacuateHost,)

View File

@ -0,0 +1,35 @@
# 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 tabs
from openstack_dashboard.api import nova
from openstack_dashboard.dashboards.admin.hypervisors.compute import tables
class ComputeHostTab(tabs.TableTab):
table_classes = (tables.ComputeHostTable,)
name = _("Compute Host")
slug = "compute_host"
template_name = "horizon/common/_detail_table.html"
def get_compute_host_data(self):
try:
services = nova.service_list(self.tab_group.request)
return [service for service in services
if service.binary == 'nova-compute']
except Exception:
msg = _('Unable to get nova services list.')
exceptions.handle(self.tab_group.request, msg)

View File

@ -0,0 +1,97 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.core.urlresolvers import reverse
from django import http
from mox import IsA # noqa
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
class EvacuateHostViewTest(test.BaseAdminViewTests):
@test.create_stubs({api.nova: ('hypervisor_list',
'hypervisor_stats',
'service_list')})
def test_index(self):
hypervisor = self.hypervisors.list().pop().hypervisor_hostname
services = [service for service in self.services.list()
if service.binary == 'nova-compute']
api.nova.service_list(IsA(http.HttpRequest),
binary='nova-compute').AndReturn(services)
self.mox.ReplayAll()
url = reverse('horizon:admin:hypervisors:compute:evacuate_host',
args=[hypervisor])
res = self.client.get(url)
self.assertTemplateUsed(res,
'admin/hypervisors/compute/evacuate_host.html')
@test.create_stubs({api.nova: ('hypervisor_list',
'hypervisor_stats',
'service_list',
'evacuate_host')})
def test_successful_post(self):
hypervisor = self.hypervisors.list().pop().hypervisor_hostname
services = [service for service in self.services.list()
if service.binary == 'nova-compute']
api.nova.service_list(IsA(http.HttpRequest),
binary='nova-compute').AndReturn(services)
api.nova.evacuate_host(IsA(http.HttpRequest),
services[1].host,
services[0].host,
False).AndReturn(True)
self.mox.ReplayAll()
url = reverse('horizon:admin:hypervisors:compute:evacuate_host',
args=[hypervisor])
form_data = {'current_host': services[1].host,
'target_host': services[0].host,
'on_shared_storage': False}
res = self.client.post(url, form_data)
dest_url = reverse('horizon:admin:hypervisors:index')
self.assertNoFormErrors(res)
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(res, dest_url)
@test.create_stubs({api.nova: ('hypervisor_list',
'hypervisor_stats',
'service_list',
'evacuate_host')})
def test_failing_nova_call_post(self):
hypervisor = self.hypervisors.list().pop().hypervisor_hostname
services = [service for service in self.services.list()
if service.binary == 'nova-compute']
api.nova.service_list(IsA(http.HttpRequest),
binary='nova-compute').AndReturn(services)
api.nova.evacuate_host(IsA(http.HttpRequest),
services[1].host,
services[0].host,
False).AndRaise(self.exceptions.nova)
self.mox.ReplayAll()
url = reverse('horizon:admin:hypervisors:compute:evacuate_host',
args=[hypervisor])
form_data = {'current_host': services[1].host,
'target_host': services[0].host,
'on_shared_storage': False}
res = self.client.post(url, form_data)
dest_url = reverse('horizon:admin:hypervisors:index')
self.assertMessageCount(error=1)
self.assertRedirectsNoFollow(res, dest_url)

View File

@ -0,0 +1,24 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls import patterns # noqa
from django.conf.urls import url # noqa
from openstack_dashboard.dashboards.admin.hypervisors.compute import views
urlpatterns = patterns(
'openstack_dashboard.dashboards.admin.hypervisors.compute.views',
url(r'^(?P<compute_host>[^/]+)/evacuate_host$',
views.EvacuateHostView.as_view(),
name='evacuate_host'),
)

View File

@ -0,0 +1,53 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from openstack_dashboard import api
from openstack_dashboard.dashboards.admin.hypervisors.compute \
import forms as project_forms
class EvacuateHostView(forms.ModalFormView):
form_class = project_forms.EvacuateHostForm
template_name = 'admin/hypervisors/compute/evacuate_host.html'
context_object_name = 'compute_host'
success_url = reverse_lazy("horizon:admin:hypervisors:index")
def get_context_data(self, **kwargs):
context = super(EvacuateHostView, self).get_context_data(**kwargs)
context["compute_host"] = self.kwargs['compute_host']
return context
def get_active_compute_hosts_names(self, *args, **kwargs):
try:
services = api.nova.service_list(self.request,
binary='nova-compute')
return [service.host for service in services
if service.state == 'up']
except Exception:
redirect = reverse("horizon:admin:hypervisors:index")
msg = _('Unable to retrieve compute host information.')
exceptions.handle(self.request, msg, redirect=redirect)
def get_initial(self):
initial = super(EvacuateHostView, self).get_initial()
hosts = self.get_active_compute_hosts_names()
current_host = self.kwargs['compute_host']
initial.update({'current_host': current_host,
'hosts': hosts})
return initial

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.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tabs
from openstack_dashboard.api import nova
from openstack_dashboard.dashboards.admin.hypervisors.compute \
import tabs as cmp_tabs
from openstack_dashboard.dashboards.admin.hypervisors import tables
class HypervisorTab(tabs.TableTab):
table_classes = (tables.AdminHypervisorsTable,)
name = _("Hypervisor")
slug = "hypervisor"
template_name = "horizon/common/_detail_table.html"
def get_hypervisors_data(self):
hypervisors = []
try:
hypervisors = nova.hypervisor_list(self.request)
except Exception:
exceptions.handle(self.request,
_('Unable to retrieve hypervisor information.'))
return hypervisors
class HypervisorHostTabs(tabs.TabGroup):
slug = "hypervisor_info"
tabs = (HypervisorTab, cmp_tabs.ComputeHostTab)
sticky = True

View File

@ -0,0 +1,25 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}evacuate_host_form{% endblock %}
{% block form_action %}{% url 'horizon:admin:hypervisors:compute:evacuate_host' compute_host %}{% endblock %}
{% block modal-header %}{% trans "Evacuate Host" %}{% endblock %}
{% block modal-body %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description:" %}</h3>
<p>{% trans "Evacuate the servers from the selected down host to an active target host." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Evacuate Host" %}" />
<a href="{% url 'horizon:admin:hypervisors:index' %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Evacuate Host" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Evacuate Host") %}
{% endblock page_header %}
{% block main %}
{% include 'admin/hypervisors/compute/_evacuate_host.html' %}
{% endblock %}

View File

@ -31,5 +31,9 @@
</strong>
</div>
</div>
{{ table.render }}
<div class="row-fluid">
<div class="col-sm-12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}

View File

@ -21,18 +21,39 @@ from openstack_dashboard.test import helpers as test
class HypervisorViewTest(test.BaseAdminViewTests):
@test.create_stubs({api.nova: ('hypervisor_list',
'hypervisor_stats')})
@test.create_stubs({api.nova: ('extension_supported',
'hypervisor_list',
'hypervisor_stats',
'service_list')})
def test_index(self):
hypervisors = self.hypervisors.list()
services = self.services.list()
stats = self.hypervisors.stats
api.nova.extension_supported('AdminActions',
IsA(http.HttpRequest)) \
.MultipleTimes().AndReturn(True)
api.nova.hypervisor_list(IsA(http.HttpRequest)).AndReturn(hypervisors)
api.nova.hypervisor_stats(IsA(http.HttpRequest)).AndReturn(stats)
api.nova.service_list(IsA(http.HttpRequest)).AndReturn(services)
self.mox.ReplayAll()
res = self.client.get(reverse('horizon:admin:hypervisors:index'))
self.assertTemplateUsed(res, 'admin/hypervisors/index.html')
self.assertItemsEqual(res.context['table'].data, hypervisors)
hypervisors_tab = res.context['tab_group'].get_tab('hypervisor')
self.assertItemsEqual(hypervisors_tab._tables['hypervisors'].data,
hypervisors)
host_tab = res.context['tab_group'].get_tab('compute_host')
host_table = host_tab._tables['compute_host']
compute_services = [service for service in services
if service.binary == 'nova-compute']
self.assertItemsEqual(host_table.data, compute_services)
actions_host_up = host_table.get_row_actions(host_table.data[0])
self.assertEqual(0, len(actions_host_up))
actions_host_down = host_table.get_row_actions(host_table.data[1])
self.assertEqual(1, len(actions_host_down))
self.assertEqual('evacuate', actions_host_down[0].name)
class HypervisorDetailViewTest(test.BaseAdminViewTests):

View File

@ -12,9 +12,12 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls import include # noqa
from django.conf.urls import patterns # noqa
from django.conf.urls import url # noqa
from openstack_dashboard.dashboards.admin.hypervisors.compute \
import urls as compute_urls
from openstack_dashboard.dashboards.admin.hypervisors import views
@ -23,5 +26,6 @@ urlpatterns = patterns(
url(r'^(?P<hypervisor>[^/]+)/$',
views.AdminDetailView.as_view(),
name='detail'),
url(r'^$', views.AdminIndexView.as_view(), name='index')
url(r'^$', views.AdminIndexView.as_view(), name='index'),
url(r'', include(compute_urls, namespace='compute')),
)

View File

@ -16,14 +16,18 @@ from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tables
from horizon import tabs
from horizon.utils import functions as utils
from openstack_dashboard import api
from openstack_dashboard.dashboards.admin.hypervisors \
import tables as project_tables
from openstack_dashboard.dashboards.admin.hypervisors \
import tabs as project_tabs
class AdminIndexView(tables.DataTableView):
table_class = project_tables.AdminHypervisorsTable
class AdminIndexView(tabs.TabbedTableView):
tab_group_class = project_tabs.HypervisorHostTabs
template_name = 'admin/hypervisors/index.html'
def get_data(self):

View File

@ -679,8 +679,20 @@ def data(TEST):
"host": "devstack001",
"disabled_reason": None,
})
service_3 = services.Service(services.ServiceManager(None), {
"status": "enabled",
"binary": "nova-compute",
"zone": "nova",
"state": "down",
"updated_at": "2013-07-08T04:20:51.000000",
"host": "devstack002",
"disabled_reason": None,
})
TEST.services.add(service_1)
TEST.services.add(service_2)
TEST.services.add(service_3)
# Aggregates
aggregate_1 = aggregates.Aggregate(aggregates.AggregateManager(None), {