Merge "Serial Console"

This commit is contained in:
Jenkins 2015-02-19 06:36:49 +00:00 committed by Gerrit Code Review
commit 738cdf26ef
14 changed files with 266 additions and 15 deletions

View File

@ -326,11 +326,13 @@ If you do not have multiple regions you should use the ``OPENSTACK_HOST`` and
Default: ``"AUTO"`` 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. VMs.
Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"`` Valid values are ``"AUTO"``(default), ``"VNC"``, ``"SPICE"``, ``"RDP"``,
and ``None`` (this latest value is available in version 2014.2(Juno) to allow ``"SERIAL"``, and ``None``.
deactivating the in-browser console). ``None`` deactivates the in-browser console and is available in version
2014.2(Juno).
``"SERIAL"`` is available since 2005.1(Kilo).
``INSTANCE_LOG_LENGTH`` ``INSTANCE_LOG_LENGTH``

View File

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

View File

@ -75,6 +75,14 @@ class RDPConsole(base.APIDictWrapper):
_attrs = ['url', 'type'] _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): class Server(base.APIResourceWrapper):
"""Simple wrapper around novaclient.server.Server. """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']) 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', def flavor_create(request, name, memory, vcpu, disk, flavorid='auto',
ephemeral=0, swap=0, metadata=None, is_public=True): ephemeral=0, swap=0, metadata=None, is_public=True):
flavor = novaclient(request).flavors.create(name, memory, vcpu, disk, flavor = novaclient(request).flavors.create(name, memory, vcpu, disk,

View File

@ -27,7 +27,8 @@ LOG = logging.getLogger(__name__)
CONSOLES = SortedDict([('VNC', api.nova.server_vnc_console), CONSOLES = SortedDict([('VNC', api.nova.server_vnc_console),
('SPICE', api.nova.server_spice_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): 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) LOG.debug('Console not available', exc_info=True)
continue continue
console_url = "%s&%s(%s)" % ( if con_type == 'SERIAL':
console.url, console_url = console.url
urlencode({'title': getattr(instance, "name", "")}), else:
instance.id) console_url = "%s&%s(%s)" % (
console.url,
urlencode({'title': getattr(instance, "name", "")}),
instance.id)
return (con_type, console_url) return (con_type, console_url)
raise exceptions.NotAvailable(_('No available console found.')) raise exceptions.NotAvailable(_('No available console found.'))

View File

@ -68,12 +68,17 @@ class ConsoleTab(tabs.Tab):
console_type = getattr(settings, 'CONSOLE_TYPE', 'AUTO') console_type = getattr(settings, 'CONSOLE_TYPE', 'AUTO')
console_url = None console_url = None
try: try:
console_url = console.get_console(request, console_type, console_type, console_url = console.get_console(
instance)[1] 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: except exceptions.NotAvailable:
exceptions.handle(request, ignore=True, force_log=True) 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): def allowed(self, request):
# The ConsoleTab is available if settings.CONSOLE_TYPE is not set at # The ConsoleTab is available if settings.CONSOLE_TYPE is not set at

View File

@ -3,7 +3,11 @@
<h3>{% trans "Instance Console" %}</h3> <h3>{% trans "Instance Console" %}</h3>
{% if console_url %} {% 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> {% 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> <iframe id="console_embed" src="{{ console_url }}" style="width:100%;height:100%"></iframe>
<script type="text/javascript"> <script type="text/javascript">

View File

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

View File

@ -4252,7 +4252,8 @@ class ConsoleManagerTests(helpers.TestCase):
console.CONSOLES = SortedDict([ console.CONSOLES = SortedDict([
('VNC', api.nova.server_vnc_console), ('VNC', api.nova.server_vnc_console),
('SPICE', api.nova.server_spice_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): def _get_console_vnc(self, server):
console_mock = self.mox.CreateMock(api.nova.VNCConsole) 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] data = console.get_console(self.request, 'RDP', server)[1]
self.assertEqual(data, url) 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): def test_get_console_auto_iterate_available(self):
server = self.servers.first() server = self.servers.first()
@ -4333,6 +4352,35 @@ class ConsoleManagerTests(helpers.TestCase):
data = console.get_console(self.request, 'AUTO', server)[1] data = console.get_console(self.request, 'AUTO', server)[1]
self.assertEqual(data, url) 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): def test_invalid_console_type_raise_value_error(self):
self.assertRaises(exceptions.NotAvailable, self.assertRaises(exceptions.NotAvailable,
console.get_console, None, 'FAKE', None) console.get_console, None, 'FAKE', None)

View File

@ -35,6 +35,8 @@ urlpatterns = patterns(
views.DetailView.as_view(), name='detail'), views.DetailView.as_view(), name='detail'),
url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'), url(INSTANCES % 'update', views.UpdateView.as_view(), name='update'),
url(INSTANCES % 'rebuild', views.RebuildView.as_view(), name='rebuild'), 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 % 'console', 'console', name='console'),
url(INSTANCES % 'vnc', 'vnc', name='vnc'), url(INSTANCES % 'vnc', 'vnc', name='vnc'),
url(INSTANCES % 'spice', 'spice', name='spice'), url(INSTANCES % 'spice', 'spice', name='spice'),

View File

@ -25,6 +25,7 @@ from django import http
from django import shortcuts from django import shortcuts
from django.utils.datastructures import SortedDict from django.utils.datastructures import SortedDict
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views import generic
from horizon import exceptions from horizon import exceptions
from horizon import forms from horizon import forms
@ -193,6 +194,35 @@ def rdp(request, instance_id):
exceptions.handle(request, msg, redirect=redirect) 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): class UpdateView(workflows.WorkflowView):
workflow_class = project_workflows.UpdateInstance workflow_class = project_workflows.UpdateInstance
success_url = reverse_lazy("horizon:project:instances:index") success_url = reverse_lazy("horizon:project:instances:index")

View File

@ -47,7 +47,7 @@ TEMPLATE_DEBUG = DEBUG
# OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default' # OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'Default'
# Set Console type: # 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. # Set to None explicitly if you want to deactivate the console.
# CONSOLE_TYPE = "AUTO" # CONSOLE_TYPE = "AUTO"

View File

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

View File

@ -36,6 +36,7 @@ import xstatic.pkg.jsencrypt
import xstatic.pkg.qunit import xstatic.pkg.qunit
import xstatic.pkg.rickshaw import xstatic.pkg.rickshaw
import xstatic.pkg.spin import xstatic.pkg.spin
import xstatic.pkg.termjs
STATICFILES_DIRS = [ STATICFILES_DIRS = [
@ -73,6 +74,8 @@ STATICFILES_DIRS = [
xstatic.main.XStatic(xstatic.pkg.rickshaw).base_dir), xstatic.main.XStatic(xstatic.pkg.rickshaw).base_dir),
('horizon/lib', ('horizon/lib',
xstatic.main.XStatic(xstatic.pkg.spin).base_dir), xstatic.main.XStatic(xstatic.pkg.spin).base_dir),
('horizon/lib',
xstatic.main.XStatic(xstatic.pkg.termjs).base_dir),
] ]

View File

@ -58,3 +58,4 @@ XStatic-QUnit>=1.14.0.2 # MIT License
XStatic-Rickshaw>=1.5.0 # BSD License (prior) XStatic-Rickshaw>=1.5.0 # BSD License (prior)
XStatic-smart-table>=1.4.5.3 # MIT License XStatic-smart-table>=1.4.5.3 # MIT License
XStatic-Spin>=1.2.5.2 # MIT License XStatic-Spin>=1.2.5.2 # MIT License
XStatic-term.js>=0.0.4 # MIT License