diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index 80f23e153c..440cb40011 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -563,6 +563,13 @@ def server_migrate(request, instance_id): novaclient(request).servers.migrate(instance_id) +def server_live_migrate(request, instance_id, host, block_migration=False, + disk_over_commit=False): + novaclient(request).servers.live_migrate(instance_id, host, + block_migration, + disk_over_commit) + + def server_resize(request, instance_id, flavor, **kwargs): novaclient(request).servers.resize(instance_id, flavor, **kwargs) diff --git a/openstack_dashboard/dashboards/admin/instances/forms.py b/openstack_dashboard/dashboards/admin/instances/forms.py new file mode 100644 index 0000000000..4098a048ca --- /dev/null +++ b/openstack_dashboard/dashboards/admin/instances/forms.py @@ -0,0 +1,80 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Kylin OS, 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. + + +from django.core.urlresolvers import reverse # noqa +from django.utils.translation import ugettext_lazy as _ # noqa + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api + + +class LiveMigrateForm(forms.SelfHandlingForm): + current_host = forms.CharField(label=_("Current Host"), + required=False, + widget=forms.TextInput( + attrs={'readonly': 'readonly'})) + host = forms.ChoiceField(label=_("New Host"), + required=True, + help_text=_("Choose a Host to migrate to.")) + disk_over_commit = forms.BooleanField(label=_("Disk Over Commit"), + initial=False, required=False) + block_migration = forms.BooleanField(label=_("Block Migration"), + initial=False, required=False) + + def __init__(self, request, *args, **kwargs): + super(LiveMigrateForm, self).__init__(request, *args, **kwargs) + initial = kwargs.get('initial', {}) + instance_id = initial.get('instance_id') + self.fields['instance_id'] = forms.CharField(widget=forms.HiddenInput, + initial=instance_id) + self.fields['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 = [(host.hypervisor_hostname, + host.hypervisor_hostname) + for host in hosts + if host.hypervisor_hostname != current_host] + if host_list: + host_list.insert(0, ("", _("Select a new host"))) + else: + host_list.insert(0, ("", _("No other hosts available."))) + return sorted(host_list) + + def handle(self, request, data): + try: + block_migration = data['block_migration'] + disk_over_commit = data['disk_over_commit'] + api.nova.server_live_migrate(request, + data['instance_id'], + data['host'], + block_migration=block_migration, + disk_over_commit=disk_over_commit) + msg = _('The instance is preparing the live migration ' + 'to host "%s".') % data['host'] + messages.success(request, msg) + return True + except Exception: + msg = _('Failed to live migrate instance to ' + 'host "%s".') % data['host'] + redirect = reverse('horizon:admin:instances:index') + exceptions.handle(request, msg, redirect=redirect) diff --git a/openstack_dashboard/dashboards/admin/instances/tables.py b/openstack_dashboard/dashboards/admin/instances/tables.py index 0a49aad672..535c7606fe 100644 --- a/openstack_dashboard/dashboards/admin/instances/tables.py +++ b/openstack_dashboard/dashboards/admin/instances/tables.py @@ -15,8 +15,10 @@ # License for the specific language governing permissions and limitations # under the License. +from django.core.urlresolvers import reverse # noqa from django.template.defaultfilters import timesince # noqa from django.template.defaultfilters import title # noqa +from django.utils.http import urlencode # noqa from django.utils.translation import ugettext_lazy as _ # noqa from horizon import tables @@ -48,6 +50,17 @@ class MigrateInstance(tables.BatchAction): api.nova.server_migrate(request, obj_id) +class LiveMigrateInstance(tables.LinkAction): + name = "live_migrate" + verbose_name = _("Live Migrate Instance") + url = "horizon:admin:instances:live_migrate" + classes = ("ajax-modal", "btn-migrate", "btn-danger") + + def allowed(self, request, instance): + return ((instance.status in project_tables.ACTIVE_STATES) + and not project_tables.is_deleting(instance)) + + class AdminUpdateRow(project_tables.UpdateRow): def get_data(self, request, instance_id): instance = super(AdminUpdateRow, self).get_data(request, instance_id) @@ -137,6 +150,7 @@ class AdminInstancesTable(tables.DataTable): project_tables.TogglePause, project_tables.ToggleSuspend, MigrateInstance, + LiveMigrateInstance, project_tables.SoftRebootInstance, project_tables.RebootInstance, project_tables.TerminateInstance) diff --git a/openstack_dashboard/dashboards/admin/instances/templates/instances/_live_migrate.html b/openstack_dashboard/dashboards/admin/instances/templates/instances/_live_migrate.html new file mode 100644 index 0000000000..a92e18be2f --- /dev/null +++ b/openstack_dashboard/dashboards/admin/instances/templates/instances/_live_migrate.html @@ -0,0 +1,25 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}live_migrate_form{% endblock %} +{% block form_action %}{% url 'horizon:admin:instances:live_migrate' instance_id %}{% endblock %} + +{% block modal-header %}{% trans "Live Migrate" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% trans "From here you can live migrate an instance to a specific host." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/instances/templates/instances/live_migrate.html b/openstack_dashboard/dashboards/admin/instances/templates/instances/live_migrate.html new file mode 100644 index 0000000000..749883b540 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/instances/templates/instances/live_migrate.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Live Migrate" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Live Migrate") %} +{% endblock page_header %} + +{% block main %} + {% include 'admin/instances/_live_migrate.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/instances/tests.py b/openstack_dashboard/dashboards/admin/instances/tests.py index 6beb8fad72..2e20c4cb40 100644 --- a/openstack_dashboard/dashboards/admin/instances/tests.py +++ b/openstack_dashboard/dashboards/admin/instances/tests.py @@ -26,6 +26,9 @@ from openstack_dashboard import api from openstack_dashboard.test import helpers as test +INDEX_URL = reverse('horizon:admin:instances:index') + + class InstanceViewTest(test.BaseAdminViewTests): @test.create_stubs({api.nova: ('flavor_list', 'server_list', 'extension_supported',), @@ -45,7 +48,7 @@ class InstanceViewTest(test.BaseAdminViewTests): api.nova.flavor_list(IsA(http.HttpRequest)).AndReturn(flavors) self.mox.ReplayAll() - res = self.client.get(reverse('horizon:admin:instances:index')) + res = self.client.get(INDEX_URL) self.assertTemplateUsed(res, 'admin/instances/index.html') instances = res.context['table'].data self.assertItemsEqual(instances, servers) @@ -75,7 +78,7 @@ class InstanceViewTest(test.BaseAdminViewTests): self.mox.ReplayAll() - res = self.client.get(reverse('horizon:admin:instances:index')) + res = self.client.get(INDEX_URL) self.assertTemplateUsed(res, 'admin/instances/index.html') instances = res.context['table'].data self.assertItemsEqual(instances, servers) @@ -107,7 +110,7 @@ class InstanceViewTest(test.BaseAdminViewTests): AndRaise(self.exceptions.nova) self.mox.ReplayAll() - res = self.client.get(reverse('horizon:admin:instances:index')) + res = self.client.get(INDEX_URL) instances = res.context['table'].data self.assertTemplateUsed(res, 'admin/instances/index.html') self.assertMessageCount(res, error=len(servers)) @@ -122,7 +125,7 @@ class InstanceViewTest(test.BaseAdminViewTests): self.mox.ReplayAll() - res = self.client.get(reverse('horizon:admin:instances:index')) + res = self.client.get(INDEX_URL) self.assertTemplateUsed(res, 'admin/instances/index.html') self.assertEqual(len(res.context['instances_table'].data), 0) @@ -144,7 +147,7 @@ class InstanceViewTest(test.BaseAdminViewTests): admin=True).AndReturn(tenant) self.mox.ReplayAll() - url = reverse('horizon:admin:instances:index') + \ + url = INDEX_URL + \ "?action=row_update&table=instances&obj_id=" + server.id res = self.client.get(url, {}, @@ -176,7 +179,7 @@ class InstanceViewTest(test.BaseAdminViewTests): AndReturn(self.flavors.list()) self.mox.ReplayAll() - res = self.client.get(reverse('horizon:admin:instances:index')) + res = self.client.get(INDEX_URL) self.assertContains(res, "instances__migrate") self.assertNotContains(res, "instances__confirm") self.assertNotContains(res, "instances__revert") @@ -202,7 +205,103 @@ class InstanceViewTest(test.BaseAdminViewTests): AndReturn(self.flavors.list()) self.mox.ReplayAll() - res = self.client.get(reverse('horizon:admin:instances:index')) + res = self.client.get(INDEX_URL) self.assertContains(res, "instances__confirm") self.assertContains(res, "instances__revert") self.assertNotContains(res, "instances__migrate") + + @test.create_stubs({api.nova: ('hypervisor_list', + 'server_get',)}) + def test_instance_live_migrate_get(self): + server = self.servers.first() + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.hypervisor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.hypervisors.list()) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:instances:live_migrate', + args=[server.id]) + res = self.client.get(url) + + self.assertTemplateUsed(res, 'admin/instances/live_migrate.html') + + @test.create_stubs({api.nova: ('server_get',)}) + def test_instance_live_migrate_get_server_get_exception(self): + server = self.servers.first() + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndRaise(self.exceptions.nova) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:instances:live_migrate', + args=[server.id]) + res = self.client.get(url) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.nova: ('hypervisor_list', + 'server_get',)}) + def test_instance_live_migrate_list_hypervisor_get_exception(self): + server = self.servers.first() + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.hypervisor_list(IsA(http.HttpRequest)) \ + .AndRaise(self.exceptions.nova) + + self.mox.ReplayAll() + url = reverse('horizon:admin:instances:live_migrate', + args=[server.id]) + res = self.client.get(url) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.nova: ('hypervisor_list', + 'server_get', + 'server_live_migrate',)}) + def test_instance_live_migrate_post(self): + server = self.servers.first() + hypervisor = self.hypervisors.first() + host = hypervisor.hypervisor_hostname + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.hypervisor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.hypervisors.list()) + api.nova.server_live_migrate(IsA(http.HttpRequest), server.id, host, + block_migration=False, + disk_over_commit=False) \ + .AndReturn([]) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:instances:live_migrate', + args=[server.id]) + res = self.client.post(url, {'host': host, 'instance_id': server.id}) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.nova: ('hypervisor_list', + 'server_get', + 'server_live_migrate',)}) + def test_instance_live_migrate_post_api_exception(self): + server = self.servers.first() + hypervisor = self.hypervisors.first() + host = hypervisor.hypervisor_hostname + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.hypervisor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.hypervisors.list()) + api.nova.server_live_migrate(IsA(http.HttpRequest), server.id, host, + block_migration=False, + disk_over_commit=False) \ + .AndRaise(self.exceptions.nova) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:instances:live_migrate', + args=[server.id]) + res = self.client.post(url, {'host': host, 'instance_id': server.id}) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/openstack_dashboard/dashboards/admin/instances/urls.py b/openstack_dashboard/dashboards/admin/instances/urls.py index c0528dad67..ecf0f97148 100644 --- a/openstack_dashboard/dashboards/admin/instances/urls.py +++ b/openstack_dashboard/dashboards/admin/instances/urls.py @@ -37,4 +37,6 @@ urlpatterns = patterns('openstack_dashboard.dashboards.admin.instances.views', url(INSTANCES % 'console', 'console', name='console'), url(INSTANCES % 'vnc', 'vnc', name='vnc'), url(INSTANCES % 'spice', 'spice', name='spice'), + url(INSTANCES % 'live_migrate', views.LiveMigrateView.as_view(), + name='live_migrate'), ) diff --git a/openstack_dashboard/dashboards/admin/instances/views.py b/openstack_dashboard/dashboards/admin/instances/views.py index 3397f25994..33f8ad6d76 100644 --- a/openstack_dashboard/dashboards/admin/instances/views.py +++ b/openstack_dashboard/dashboards/admin/instances/views.py @@ -19,13 +19,18 @@ # License for the specific language governing permissions and limitations # under the License. +from django.core.urlresolvers import reverse # noqa +from django.core.urlresolvers import reverse_lazy # noqa from django.utils.datastructures import SortedDict # noqa from django.utils.translation import ugettext_lazy as _ # noqa from horizon import exceptions +from horizon import forms from horizon import tables from openstack_dashboard import api +from openstack_dashboard.dashboards.admin.instances \ + import forms as project_forms from openstack_dashboard.dashboards.admin.instances \ import tables as project_tables from openstack_dashboard.dashboards.project.instances import views @@ -108,3 +113,46 @@ class AdminIndexView(tables.DataTableView): tenant = tenant_dict.get(inst.tenant_id, None) inst.tenant_name = getattr(tenant, "name", None) return instances + + +class LiveMigrateView(forms.ModalFormView): + form_class = project_forms.LiveMigrateForm + template_name = 'admin/instances/live_migrate.html' + context_object_name = 'instance' + success_url = reverse_lazy("horizon:admin:instances:index") + + def get_context_data(self, **kwargs): + context = super(LiveMigrateView, self).get_context_data(**kwargs) + context["instance_id"] = self.kwargs['instance_id'] + return context + + def get_hosts(self, *args, **kwargs): + if not hasattr(self, "_hosts"): + try: + self._hosts = api.nova.hypervisor_list(self.request) + except Exception: + redirect = reverse("horizon:admin:instances:index") + msg = _('Unable to retrieve hypervisor information.') + exceptions.handle(self.request, msg, redirect=redirect) + return self._hosts + + def get_object(self, *args, **kwargs): + if not hasattr(self, "_object"): + instance_id = self.kwargs['instance_id'] + try: + self._object = api.nova.server_get(self.request, instance_id) + except Exception: + redirect = reverse("horizon:admin:instances:index") + msg = _('Unable to retrieve instance details.') + exceptions.handle(self.request, msg, redirect=redirect) + return self._object + + def get_initial(self): + initial = super(LiveMigrateView, self).get_initial() + _object = self.get_object() + if _object: + current_host = getattr(_object, 'OS-EXT-SRV-ATTR:host', '') + initial.update({'instance_id': self.kwargs['instance_id'], + 'current_host': current_host, + 'hosts': self.get_hosts()}) + return initial