diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index 65694df52e..221b035748 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -722,6 +722,18 @@ def server_metadata_delete(request, instance_id, keys): novaclient(request).servers.delete_meta(instance_id, keys) +@profiler.trace +def server_rescue(request, instance_id, password=None, image=None): + novaclient(request).servers.rescue(instance_id, + password=password, + image=image) + + +@profiler.trace +def server_unrescue(request, instance_id): + novaclient(request).servers.unrescue(instance_id) + + @profiler.trace def tenant_quota_get(request, tenant_id): return QuotaSet(novaclient(request).quotas.get(tenant_id)) diff --git a/openstack_dashboard/dashboards/admin/instances/forms.py b/openstack_dashboard/dashboards/admin/instances/forms.py index a63fda2024..ba898c1067 100644 --- a/openstack_dashboard/dashboards/admin/instances/forms.py +++ b/openstack_dashboard/dashboards/admin/instances/forms.py @@ -21,6 +21,8 @@ from horizon import forms from horizon import messages from openstack_dashboard import api +from openstack_dashboard.dashboards.project.instances \ + import forms as project_forms class LiveMigrateForm(forms.SelfHandlingForm): @@ -78,3 +80,7 @@ class LiveMigrateForm(forms.SelfHandlingForm): msg = _('Failed to live migrate instance to a new host.') redirect = reverse('horizon:admin:instances:index') exceptions.handle(request, msg, redirect=redirect) + + +class RescueInstanceForm(project_forms.RescueInstanceForm): + failure_url = 'horizon:admin:instances:index' diff --git a/openstack_dashboard/dashboards/admin/instances/tables.py b/openstack_dashboard/dashboards/admin/instances/tables.py index 668703276a..95053f6ec6 100644 --- a/openstack_dashboard/dashboards/admin/instances/tables.py +++ b/openstack_dashboard/dashboards/admin/instances/tables.py @@ -42,6 +42,10 @@ class AdminLogLink(project_tables.LogLink): url = "horizon:admin:instances:detail" +class RescueInstance(project_tables.RescueInstance): + url = "horizon:admin:instances:rescue" + + class MigrateInstance(policy.PolicyTargetMixin, tables.BatchAction): name = "migrate" classes = ("btn-migrate",) @@ -190,7 +194,9 @@ class AdminInstancesTable(tables.DataTable): table_actions = (project_tables.DeleteInstance, AdminInstanceFilterAction) row_class = AdminUpdateRow - row_actions = (project_tables.ConfirmResize, + row_actions = (RescueInstance, + project_tables.UnRescueInstance, + project_tables.ConfirmResize, project_tables.RevertResize, AdminEditInstance, AdminConsoleLink, diff --git a/openstack_dashboard/dashboards/admin/instances/templates/instances/_rescue.html b/openstack_dashboard/dashboards/admin/instances/templates/instances/_rescue.html new file mode 100644 index 0000000000..1fdefe63e4 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/instances/templates/instances/_rescue.html @@ -0,0 +1,27 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block form_id %}rescue_instance_form{% endblock %} +{% block form_action %}{% url "horizon:admin:instances:rescue" instance_id %}{% endblock %} + +{% block modal_id %}rescue_instance_modal{% endblock %} +{% block modal-header %}{% trans "Rescue Instance" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% trans "The rescue mode is only for emergency purpose, for example in case of a system or access failure." %}

+

+ {% blocktrans trimmed %} + This will shut down your instance and mount the root disk to a temporary server. + Then, you will be able to connect to this server, repair the system configuration or recover your data. + {% endblocktrans %} +

+

{% trans "You may optionally select an image and set a password on the rescue instance server." %}

+
+{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/instances/templates/instances/rescue.html b/openstack_dashboard/dashboards/admin/instances/templates/instances/rescue.html new file mode 100644 index 0000000000..e3938cb234 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/instances/templates/instances/rescue.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}{% trans "Rescue Instance" %}{% endblock %} + +{% block main %} + {% include "admin/instances/_rescue.html" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/instances/urls.py b/openstack_dashboard/dashboards/admin/instances/urls.py index e33ae4cfd2..7029d46fc0 100644 --- a/openstack_dashboard/dashboards/admin/instances/urls.py +++ b/openstack_dashboard/dashboards/admin/instances/urls.py @@ -35,4 +35,5 @@ urlpatterns = [ url(INSTANCES % 'rdp', views.rdp, name='rdp'), url(INSTANCES % 'live_migrate', views.LiveMigrateView.as_view(), name='live_migrate'), + url(INSTANCES % 'rescue', views.RescueView.as_view(), name='rescue'), ] diff --git a/openstack_dashboard/dashboards/admin/instances/views.py b/openstack_dashboard/dashboards/admin/instances/views.py index 63454d89cf..7428657b4d 100644 --- a/openstack_dashboard/dashboards/admin/instances/views.py +++ b/openstack_dashboard/dashboards/admin/instances/views.py @@ -247,3 +247,13 @@ class DetailView(views.DetailView): def _get_actions(self, instance): table = project_tables.AdminInstancesTable(self.request) return table.render_row_actions(instance) + + +class RescueView(views.RescueView): + form_class = project_forms.RescueInstanceForm + submit_url = "horizon:admin:instances:rescue" + success_url = reverse_lazy('horizon:admin:instances:index') + template_name = 'admin/instances/rescue.html' + + def get_initial(self): + return {'instance_id': self.kwargs["instance_id"]} diff --git a/openstack_dashboard/dashboards/project/instances/forms.py b/openstack_dashboard/dashboards/project/instances/forms.py index 009892d813..f3d510a306 100644 --- a/openstack_dashboard/dashboards/project/instances/forms.py +++ b/openstack_dashboard/dashboards/project/instances/forms.py @@ -475,3 +475,39 @@ class Disassociate(forms.SelfHandlingForm): _('Unable to disassociate floating IP %s') % fip.ip, redirect=redirect) return True + + +class RescueInstanceForm(forms.SelfHandlingForm): + image = forms.ChoiceField( + label=_("Select Image"), + widget=forms.ThemableSelectWidget( + attrs={'class': 'image-selector'}, + data_attrs=('size', 'display-name'), + transform=_image_choice_title)) + password = forms.CharField(label=_("Password"), max_length=255, + required=False, + widget=forms.PasswordInput(render_value=False)) + failure_url = 'horizon:project:instances:index' + + def __init__(self, request, *args, **kwargs): + super(RescueInstanceForm, self).__init__(request, *args, **kwargs) + images = image_utils.get_available_images(request, + request.user.tenant_id) + choices = [(image.id, image) for image in images] + if not choices: + choices.insert(0, ("", _("No images available"))) + self.fields['image'].choices = choices + + def handle(self, request, data): + try: + api.nova.server_rescue(request, self.initial["instance_id"], + password=data["password"], + image=data["image"]) + messages.success(request, + _('Successfully rescued instance')) + return True + except Exception: + redirect = reverse(self.failure_url) + exceptions.handle(request, + _('Unable to rescue instance'), + redirect=redirect) diff --git a/openstack_dashboard/dashboards/project/instances/tables.py b/openstack_dashboard/dashboards/project/instances/tables.py index 5ae25e01f5..342b7f9ca5 100644 --- a/openstack_dashboard/dashboards/project/instances/tables.py +++ b/openstack_dashboard/dashboards/project/instances/tables.py @@ -175,6 +175,49 @@ class SoftRebootInstance(RebootInstance): return True +class RescueInstance(policy.PolicyTargetMixin, tables.LinkAction): + name = "rescue" + verbose_name = _("Rescue Instance") + classes = ("btn-rescue", "ajax-modal") + url = "horizon:project:instances:rescue" + + def get_link_url(self, datum): + instance_id = self.table.get_object_id(datum) + return urls.reverse(self.url, args=[instance_id]) + + def allowed(self, request, instance): + return instance.status in ACTIVE_STATES + + +class UnRescueInstance(tables.BatchAction): + name = 'unrescue' + classes = ("btn-unrescue",) + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Unrescue Instance", + u"Unrescue Instances", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Unrescued Instance", + u"Unrescued Instances", + count + ) + + def action(self, request, obj_id): + api.nova.server_unrescue(request, obj_id) + + def allowed(self, request, instance=None): + if instance: + return instance.status == "RESCUE" + return False + + class TogglePause(tables.BatchAction): name = "pause" icon = "pause" @@ -1263,6 +1306,7 @@ class InstancesTable(tables.DataTable): EditInstanceSecurityGroups, EditPortSecurityGroups, ConsoleLink, LogLink, + RescueInstance, UnRescueInstance, TogglePause, ToggleSuspend, ToggleShelve, ResizeLink, LockInstance, UnlockInstance, SoftRebootInstance, RebootInstance, diff --git a/openstack_dashboard/dashboards/project/instances/templates/instances/_rescue.html b/openstack_dashboard/dashboards/project/instances/templates/instances/_rescue.html new file mode 100644 index 0000000000..ca9504ad56 --- /dev/null +++ b/openstack_dashboard/dashboards/project/instances/templates/instances/_rescue.html @@ -0,0 +1,27 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block form_id %}rescue_instance_form{% endblock %} +{% block form_action %}{% url "horizon:project:instances:rescue" instance_id %}{% endblock %} + +{% block modal_id %}rescue_instance_modal{% endblock %} +{% block modal-header %}{% trans "Rescue Instance" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description:" %}

+

{% trans "The rescue mode is only for emergency purpose, for example in case of a system or access failure." %}

+

+ {% blocktrans trimmed %} + This will shut down your instance and mount the root disk to a temporary server. + Then, you will be able to connect to this server, repair the system configuration or recover your data. + {% endblocktrans %} +

+

{% trans "You may optionally select an image and set a password on the rescue instance server." %}

+
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/instances/templates/instances/rescue.html b/openstack_dashboard/dashboards/project/instances/templates/instances/rescue.html new file mode 100644 index 0000000000..fe811cf00b --- /dev/null +++ b/openstack_dashboard/dashboards/project/instances/templates/instances/rescue.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}{% trans "Rescue Instance" %}{% endblock %} + +{% block main %} + {% include "project/instances/_rescue.html" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index 52cd873b3e..d2bbcfdad9 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -5023,6 +5023,66 @@ class InstanceTests2(InstanceTestBase, InstanceTableTestMixin): cleaned, precleaned) + def _server_rescue_post(self, server_id, image_id, + password=None): + form_data = {'instance_id': server_id, + 'image': image_id} + if password is not None: + form_data["password"] = password + url = reverse('horizon:project:instances:rescue', + args=[server_id]) + return self.client.post(url, form_data) + + @helpers.create_mocks({api.nova: ('server_rescue',), + api.glance: ('image_list_detailed',)}) + def test_rescue_instance_post(self): + server = self.servers.first() + image = self.images.first() + password = u'testpass' + self._mock_glance_image_list_detailed(self.images.list()) + self.mock_server_rescue.return_value = [] + res = self._server_rescue_post(server.id, image.id, + password=password) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + self._check_glance_image_list_detailed(count=3) + self.mock_server_rescue.assert_called_once_with( + helpers.IsHttpRequest(), server.id, image=image.id, + password=password) + + @helpers.create_mocks({api.nova: ('server_list', + 'flavor_list', + 'server_unrescue',), + api.glance: ('image_list_detailed',), + api.network: ('servers_update_addresses',)}) + def test_unrescue_instance(self): + servers = self.servers.list() + server = servers[0] + server.status = "RESCUE" + + self.mock_server_list.return_value = [servers, False] + self.mock_servers_update_addresses.return_value = None + self.mock_flavor_list.return_value = self.flavors.list() + self.mock_image_list_detailed.return_value = (self.images.list(), + False, False) + self.mock_server_unrescue.return_value = None + + formData = {'action': 'instances__unrescue__%s' % server.id} + res = self.client.post(INDEX_URL, formData) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + search_opts = {'marker': None, 'paginate': True} + self.mock_server_list.assert_called_once_with(helpers.IsHttpRequest(), + search_opts=search_opts) + self.mock_servers_update_addresses.assert_called_once_with( + helpers.IsHttpRequest(), servers) + self.mock_flavor_list.assert_called_once_with(helpers.IsHttpRequest()) + self.mock_image_list_detailed.assert_called_once_with( + helpers.IsHttpRequest()) + self.mock_server_unrescue.assert_called_once_with( + helpers.IsHttpRequest(), server.id) + class InstanceAjaxTests(helpers.TestCase, InstanceTestHelperMixin): @helpers.create_mocks({api.nova: ("server_get", diff --git a/openstack_dashboard/dashboards/project/instances/urls.py b/openstack_dashboard/dashboards/project/instances/urls.py index 4deb8055d7..b531ce996b 100644 --- a/openstack_dashboard/dashboards/project/instances/urls.py +++ b/openstack_dashboard/dashboards/project/instances/urls.py @@ -57,4 +57,5 @@ urlpatterns = [ ), url(r'^(?P[^/]+)/ports/(?P[^/]+)/update$', views.UpdatePortView.as_view(), name='update_port'), + url(INSTANCES % 'rescue', views.RescueView.as_view(), name='rescue'), ] diff --git a/openstack_dashboard/dashboards/project/instances/views.py b/openstack_dashboard/dashboards/project/instances/views.py index 9d39f43c62..fa2e833b79 100644 --- a/openstack_dashboard/dashboards/project/instances/views.py +++ b/openstack_dashboard/dashboards/project/instances/views.py @@ -678,3 +678,22 @@ class UpdatePortView(port_views.UpdateView): initial = super(UpdatePortView, self).get_initial() initial['instance_id'] = self.kwargs['instance_id'] return initial + + +class RescueView(forms.ModalFormView): + form_class = project_forms.RescueInstanceForm + template_name = 'project/instances/rescue.html' + submit_label = _("Confirm") + submit_url = "horizon:project:instances:rescue" + success_url = reverse_lazy('horizon:project:instances:index') + page_title = _("Rescue Instance") + + def get_context_data(self, **kwargs): + context = super(RescueView, self).get_context_data(**kwargs) + context["instance_id"] = self.kwargs['instance_id'] + args = (self.kwargs['instance_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_initial(self): + return {'instance_id': self.kwargs["instance_id"]} diff --git a/releasenotes/notes/bp-support-rescue-instance-a7b8578c395abd3e.yaml b/releasenotes/notes/bp-support-rescue-instance-a7b8578c395abd3e.yaml new file mode 100644 index 0000000000..ca75b871b1 --- /dev/null +++ b/releasenotes/notes/bp-support-rescue-instance-a7b8578c395abd3e.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + [:blueprint:`instance-rescue-horizon-support`] + Support instance rescue feature