From 6ab60fde7ff5c9f8420ad97c09e2d428095814f9 Mon Sep 17 00:00:00 2001 From: Randy Bertram Date: Wed, 31 Dec 2014 14:51:20 -0500 Subject: [PATCH] Serial Console Adds support for serial console, in addition to VNC, SPICE, and RDP. Depends on term.js being added to OpenStack global requirements: https://review.openstack.org/#/c/145825 To try this patch: 1. In `nova.conf`: [serial_console] enable=True base_url = ws://:6083/ 2. Set CONSOLE_TYPE = "SERIAL" in local_settings.py. 3. You may need to start nova-serialproxy. 4. You may need to port-forward 6083. https://review.openstack.org/#/c/143615 will make the serial console available from Network Topology, along with other consoles. Co-Authored-By: Richard Jones Implements blueprint serial-console Change-Id: If83c4efa1a96f9d393110af27f90a0808a23e641 --- doc/source/topics/settings.rst | 10 +- .../js/angular/directives/serialConsole.js | 95 +++++++++++++++++++ openstack_dashboard/api/nova.py | 13 +++ .../dashboards/project/instances/console.py | 15 ++- .../dashboards/project/instances/tabs.py | 11 ++- .../templates/instances/_detail_console.html | 6 +- .../templates/instances/serial_console.html | 24 +++++ .../dashboards/project/instances/tests.py | 50 +++++++++- .../dashboards/project/instances/urls.py | 2 + .../dashboards/project/instances/views.py | 30 ++++++ .../local/local_settings.py.example | 2 +- .../static/dashboard/scss/serial_console.css | 19 ++++ openstack_dashboard/static_settings.py | 3 + requirements.txt | 1 + 14 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 horizon/static/horizon/js/angular/directives/serialConsole.js create mode 100644 openstack_dashboard/dashboards/project/instances/templates/instances/serial_console.html create mode 100644 openstack_dashboard/static/dashboard/scss/serial_console.css diff --git a/doc/source/topics/settings.rst b/doc/source/topics/settings.rst index 377a728ad6..063350d07c 100755 --- a/doc/source/topics/settings.rst +++ b/doc/source/topics/settings.rst @@ -326,11 +326,13 @@ If you do not have multiple regions you should use the ``OPENSTACK_HOST`` and Default: ``"AUTO"`` -This setting specifies the type of in-browser VNC console used to access the +This setting specifies the type of in-browser console used to access the VMs. -Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"`` -and ``None`` (this latest value is available in version 2014.2(Juno) to allow -deactivating the in-browser console). +Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"``, +``"SERIAL"``, and ``None``. +``None`` deactivates the in-browser console and is available in version +2014.2(Juno). +``"SERIAL"`` is available since 2005.1(Kilo). ``INSTANCE_LOG_LENGTH`` diff --git a/horizon/static/horizon/js/angular/directives/serialConsole.js b/horizon/static/horizon/js/angular/directives/serialConsole.js new file mode 100644 index 0000000000..e50282f23d --- /dev/null +++ b/horizon/static/horizon/js/angular/directives/serialConsole.js @@ -0,0 +1,95 @@ +/* +Copyright 2014, Rackspace, US, 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. +*/ + +/*global Terminal,Blob,FileReader,gettext,interpolate */ +(function() { + 'use strict'; + + angular.module('serialConsoleApp', []) + .constant('protocols', ['binary', 'base64']) + .constant('states', [gettext('Connecting'), gettext('Open'), gettext('Closing'), gettext('Closed')]) + + /** + * @ngdoc directive + * @ngname serialConsole + * + * @description + * The serial-console element creates a terminal based on the widely-used term.js. + * The "connection" attribute is input to a WebSocket object, which connects + * to a server. In Horizon, this directive is used to connect to nova-serialproxy, + * opening a serial console to any instance. Each key the user types is transmitted + * to the instance, and each character the instance reponds with is displayed. + */ + .directive('serialConsole', function(protocols, states) { + return { + scope: true, + template: '

{{statusMessage()}}', + restrict: 'E', + link: function postLink(scope, element, attrs) { + + var connection = scope.$eval(attrs.connection); + var term = new Terminal(); + var socket = new WebSocket(connection, protocols); + + socket.onerror = function() { + scope.$apply(scope.status); + }; + socket.onopen = function() { + scope.$apply(scope.status); + // initialize by "hitting enter" + socket.send(String.fromCharCode(13)); + }; + socket.onclose = function() { + scope.$apply(scope.status); + }; + + // turn the angular jQlite element into a raw DOM element so we can + // attach the Terminal to it + var termElement = angular.element(element)[0]; + term.open(termElement.ownerDocument.getElementById('terminalNode')); + + term.on('data', function(data) { + socket.send(data); + }); + + socket.onmessage = function(e) { + if (e.data instanceof Blob) { + var f = new FileReader(); + f.onload = function() { + term.write(f.result); + }; + f.readAsText(e.data); + } else { + term.write(e.data); + } + }; + + scope.status = function() { + return states[socket.readyState]; + }; + + scope.statusMessage = function() { + return interpolate(gettext('Status: %s'), [scope.status()]); + }; + + scope.$on('$destroy', function() { + socket.close(); + }); + + } + }; + }); +}()); \ No newline at end of file diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index 0e1221dc36..15bf8cb74c 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -75,6 +75,14 @@ class RDPConsole(base.APIDictWrapper): _attrs = ['url', 'type'] +class SerialConsole(base.APIDictWrapper): + """Wrapper for the "console" dictionary. + + Returned by the novaclient.servers.get_serial_console method. + """ + _attrs = ['url', 'type'] + + class Server(base.APIResourceWrapper): """Simple wrapper around novaclient.server.Server. @@ -455,6 +463,11 @@ def server_rdp_console(request, instance_id, console_type='rdp-html5'): instance_id, console_type)['console']) +def server_serial_console(request, instance_id, console_type='serial'): + return SerialConsole(novaclient(request).servers.get_serial_console( + instance_id, console_type)['console']) + + def flavor_create(request, name, memory, vcpu, disk, flavorid='auto', ephemeral=0, swap=0, metadata=None, is_public=True): flavor = novaclient(request).flavors.create(name, memory, vcpu, disk, diff --git a/openstack_dashboard/dashboards/project/instances/console.py b/openstack_dashboard/dashboards/project/instances/console.py index 5d68f50760..d865212b4c 100644 --- a/openstack_dashboard/dashboards/project/instances/console.py +++ b/openstack_dashboard/dashboards/project/instances/console.py @@ -27,7 +27,8 @@ LOG = logging.getLogger(__name__) CONSOLES = SortedDict([('VNC', api.nova.server_vnc_console), ('SPICE', api.nova.server_spice_console), - ('RDP', api.nova.server_rdp_console)]) + ('RDP', api.nova.server_rdp_console), + ('SERIAL', api.nova.server_serial_console)]) def get_console(request, console_type, instance): @@ -58,10 +59,14 @@ def get_console(request, console_type, instance): LOG.debug('Console not available', exc_info=True) continue - console_url = "%s&%s(%s)" % ( - console.url, - urlencode({'title': getattr(instance, "name", "")}), - instance.id) + if con_type == 'SERIAL': + console_url = console.url + else: + console_url = "%s&%s(%s)" % ( + console.url, + urlencode({'title': getattr(instance, "name", "")}), + instance.id) + return (con_type, console_url) raise exceptions.NotAvailable(_('No available console found.')) diff --git a/openstack_dashboard/dashboards/project/instances/tabs.py b/openstack_dashboard/dashboards/project/instances/tabs.py index c81f6599b9..e58b4c5b47 100644 --- a/openstack_dashboard/dashboards/project/instances/tabs.py +++ b/openstack_dashboard/dashboards/project/instances/tabs.py @@ -68,12 +68,17 @@ class ConsoleTab(tabs.Tab): console_type = getattr(settings, 'CONSOLE_TYPE', 'AUTO') console_url = None try: - console_url = console.get_console(request, console_type, - instance)[1] + console_type, console_url = console.get_console( + request, console_type, instance) + # For serial console, the url is different from VNC, etc. + # because it does not include parms for title and token + if console_type == "SERIAL": + console_url = "/project/instances/%s/serial" % (instance.id) except exceptions.NotAvailable: exceptions.handle(request, ignore=True, force_log=True) - return {'console_url': console_url, 'instance_id': instance.id} + return {'console_url': console_url, 'instance_id': instance.id, + 'console_type': console_type} def allowed(self, request): # The ConsoleTab is available if settings.CONSOLE_TYPE is not set at diff --git a/openstack_dashboard/dashboards/project/instances/templates/instances/_detail_console.html b/openstack_dashboard/dashboards/project/instances/templates/instances/_detail_console.html index e6cf5be198..28cddcc6e9 100644 --- a/openstack_dashboard/dashboards/project/instances/templates/instances/_detail_console.html +++ b/openstack_dashboard/dashboards/project/instances/templates/instances/_detail_console.html @@ -3,7 +3,11 @@

{% trans "Instance Console" %}

{% if console_url %} -

{% blocktrans %}If console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %} {% trans "Click here to show only console" %}
+

+{% if console_type != 'SERIAL' %} +{% blocktrans %}If console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %} +{% endif %} +{% trans "Click here to show only console" %}
{% trans "To exit the fullscreen mode, click the browser's back button." %}

+ + + + + + + + {% if error_message %} + {{ error_message }} + {% else %} + + {% endif %} + + + \ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index bbf570381c..a5fe31955c 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -4252,7 +4252,8 @@ class ConsoleManagerTests(helpers.TestCase): console.CONSOLES = SortedDict([ ('VNC', api.nova.server_vnc_console), ('SPICE', api.nova.server_spice_console), - ('RDP', api.nova.server_rdp_console)]) + ('RDP', api.nova.server_rdp_console), + ('SERIAL', api.nova.server_serial_console)]) def _get_console_vnc(self, server): console_mock = self.mox.CreateMock(api.nova.VNCConsole) @@ -4308,6 +4309,24 @@ class ConsoleManagerTests(helpers.TestCase): data = console.get_console(self.request, 'RDP', server)[1] self.assertEqual(data, url) + def _get_console_serial(self, server): + console_mock = self.mox.CreateMock(api.nova.SerialConsole) + console_mock.url = '/SERIAL' + + self.mox.StubOutWithMock(api.nova, 'server_serial_console') + api.nova.server_serial_console(IgnoreArg(), server.id) \ + .AndReturn(console_mock) + + self.mox.ReplayAll() + self.setup_consoles() + + def test_get_console_serial(self): + server = self.servers.first() + self._get_console_serial(server) + url = '/SERIAL' + data = console.get_console(self.request, 'SERIAL', server)[1] + self.assertEqual(data, url) + def test_get_console_auto_iterate_available(self): server = self.servers.first() @@ -4333,6 +4352,35 @@ class ConsoleManagerTests(helpers.TestCase): data = console.get_console(self.request, 'AUTO', server)[1] self.assertEqual(data, url) + def test_get_console_auto_iterate_serial_available(self): + server = self.servers.first() + + console_mock = self.mox.CreateMock(api.nova.SerialConsole) + console_mock.url = '/SERIAL' + + self.mox.StubOutWithMock(api.nova, 'server_vnc_console') + api.nova.server_vnc_console(IgnoreArg(), server.id) \ + .AndRaise(self.exceptions.nova) + + self.mox.StubOutWithMock(api.nova, 'server_spice_console') + api.nova.server_spice_console(IgnoreArg(), server.id) \ + .AndRaise(self.exceptions.nova) + + self.mox.StubOutWithMock(api.nova, 'server_rdp_console') + api.nova.server_rdp_console(IgnoreArg(), server.id) \ + .AndRaise(self.exceptions.nova) + + self.mox.StubOutWithMock(api.nova, 'server_serial_console') + api.nova.server_serial_console(IgnoreArg(), server.id) \ + .AndReturn(console_mock) + + self.mox.ReplayAll() + self.setup_consoles() + + url = '/SERIAL' + data = console.get_console(self.request, 'AUTO', server)[1] + self.assertEqual(data, url) + def test_invalid_console_type_raise_value_error(self): self.assertRaises(exceptions.NotAvailable, console.get_console, None, 'FAKE', None) diff --git a/openstack_dashboard/dashboards/project/instances/urls.py b/openstack_dashboard/dashboards/project/instances/urls.py index fe82a4d074..71c0ddefd1 100644 --- a/openstack_dashboard/dashboards/project/instances/urls.py +++ b/openstack_dashboard/dashboards/project/instances/urls.py @@ -35,6 +35,8 @@ urlpatterns = patterns( views.DetailView.as_view(), name='detail'), url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'), url(INSTANCES % 'rebuild', views.RebuildView.as_view(), name='rebuild'), + url(INSTANCES % 'serial', views.SerialConsoleView.as_view(), + name='serial'), url(INSTANCES % 'console', 'console', name='console'), url(INSTANCES % 'vnc', 'vnc', name='vnc'), url(INSTANCES % 'spice', 'spice', name='spice'), diff --git a/openstack_dashboard/dashboards/project/instances/views.py b/openstack_dashboard/dashboards/project/instances/views.py index 4cce88b3ef..6d4999d788 100644 --- a/openstack_dashboard/dashboards/project/instances/views.py +++ b/openstack_dashboard/dashboards/project/instances/views.py @@ -25,6 +25,7 @@ from django import http from django import shortcuts from django.utils.datastructures import SortedDict from django.utils.translation import ugettext_lazy as _ +from django.views import generic from horizon import exceptions from horizon import forms @@ -193,6 +194,35 @@ def rdp(request, instance_id): exceptions.handle(request, msg, redirect=redirect) +class SerialConsoleView(generic.TemplateView): + template_name = 'project/instances/serial_console.html' + + def get_context_data(self, **kwargs): + context = super(SerialConsoleView, self).get_context_data(**kwargs) + context['instance_id'] = self.kwargs['instance_id'] + instance = None + try: + instance = api.nova.server_get(context['view'].request, + self.kwargs['instance_id']) + except Exception: + context["error_message"] = _( + "Cannot find instance %s.") % self.kwargs['instance_id'] + # name is unknown, so leave it blank for the window title + # in full-screen mode, so only the instance id is shown. + context['instance_name'] = '' + return context + context['instance_name'] = instance.name + try: + console_url = project_console.get_console(context['view'].request, + "SERIAL", instance)[1] + context["console_url"] = console_url + except exceptions.NotAvailable: + context["error_message"] = _( + "Cannot get console for instance %s.") % self.kwargs[ + 'instance_id'] + return context + + class UpdateView(workflows.WorkflowView): workflow_class = project_workflows.UpdateInstance success_url = reverse_lazy("horizon:project:instances:index") diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index b6fa507430..be3303b0d2 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -47,7 +47,7 @@ TEMPLATE_DEBUG = DEBUG # OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default' # Set Console type: -# valid options would be "AUTO"(default), "VNC", "SPICE", "RDP" or None +# valid options would be "AUTO"(default), "VNC", "SPICE", "RDP", "SERIAL" or None # Set to None explicitly if you want to deactivate the console. # CONSOLE_TYPE = "AUTO" diff --git a/openstack_dashboard/static/dashboard/scss/serial_console.css b/openstack_dashboard/static/dashboard/scss/serial_console.css new file mode 100644 index 0000000000..1dbb4fd0f3 --- /dev/null +++ b/openstack_dashboard/static/dashboard/scss/serial_console.css @@ -0,0 +1,19 @@ +/* Stand-alone CSS file for Serial Console IFrame. */ + +#terminalNode { + display:inline-block; +} + +.terminal { + float: left; + border: black solid 5px; + font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; + font-size: 16px; + color: white; + background: black; +} + +.terminal-cursor { + color: black; + background: white; +}; \ No newline at end of file diff --git a/openstack_dashboard/static_settings.py b/openstack_dashboard/static_settings.py index aabe13df76..615f394c73 100644 --- a/openstack_dashboard/static_settings.py +++ b/openstack_dashboard/static_settings.py @@ -36,6 +36,7 @@ import xstatic.pkg.jsencrypt import xstatic.pkg.qunit import xstatic.pkg.rickshaw import xstatic.pkg.spin +import xstatic.pkg.termjs STATICFILES_DIRS = [ @@ -73,6 +74,8 @@ STATICFILES_DIRS = [ xstatic.main.XStatic(xstatic.pkg.rickshaw).base_dir), ('horizon/lib', xstatic.main.XStatic(xstatic.pkg.spin).base_dir), + ('horizon/lib', + xstatic.main.XStatic(xstatic.pkg.termjs).base_dir), ] diff --git a/requirements.txt b/requirements.txt index b54d18583a..be21144eff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,3 +58,4 @@ XStatic-QUnit>=1.14.0.2 # MIT License XStatic-Rickshaw>=1.5.0 # BSD License (prior) XStatic-smart-table>=1.4.5.3 # MIT License XStatic-Spin>=1.2.5.2 # MIT License +XStatic-term.js>=0.0.4 # MIT License