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://<location-of-serial-console-proxy>: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<r1chardj0n3s@gmail.com>

Implements blueprint serial-console

Change-Id: If83c4efa1a96f9d393110af27f90a0808a23e641
This commit is contained in:
Randy Bertram 2014-12-31 14:51:20 -05:00
parent b7956c9c65
commit 6ab60fde7f
14 changed files with 266 additions and 15 deletions
doc/source/topics
horizon/static/horizon/js/angular/directives
openstack_dashboard
requirements.txt

@ -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``

@ -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: '<div id="terminalNode"></div><br>{{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();
});
}
};
});
}());

@ -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,

@ -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.'))

@ -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

@ -3,7 +3,11 @@
<h3>{% trans "Instance Console" %}</h3>
{% if console_url %}
<p class='alert alert-info'>{% blocktrans %}If console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %} <a href="{{ console_url }}" style="text-decoration: underline">{% trans "Click here to show only console" %}</a><br />
<p class='alert alert-info'>
{% if console_type != 'SERIAL' %}
{% blocktrans %}If console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %}
{% endif %}
<a href="{{ console_url }}" style="text-decoration: underline">{% trans "Click here to show only console" %}</a><br />
{% trans "To exit the fullscreen mode, click the browser's back button." %}</p>
<iframe id="console_embed" src="{{ console_url }}" style="width:100%;height:100%"></iframe>
<script type="text/javascript">

@ -0,0 +1,24 @@
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta content='IE=edge' http-equiv='X-UA-Compatible' />
<meta content='text/html; charset=utf-8' http-equiv='Content-Type' />
<title>{{instance_name}} ({{instance_id}})</title>
<link rel="stylesheet" href="{{ STATIC_URL }}dashboard/scss/serial_console.css" type="text/css" media="screen">
<script src="{% url 'horizon:jsi18n' 'horizon' %}"></script>
<script src='{{ STATIC_URL }}horizon/lib/term.js'></script>
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/angular/angular.js"></script>
<script src="{{ STATIC_URL }}horizon/js/angular/directives/serialConsole.js"></script>
</head>
<body ng-app='serialConsoleApp'>
{% if error_message %}
{{ error_message }}
{% else %}
<serial-console connection='"{{console_url}}"'></serial-console>
{% endif %}
</body>
</html>

@ -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)

@ -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'),

@ -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")

@ -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"

@ -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;
};

@ -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),
]

@ -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