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