Nova-compatible serial console: socat console_utils

This adds console_utils functions for 'socat' console.
Implements:
- get_socat_console_url(): returns url for socat console
- start_socat_console(): uses (socat + console_cmd execution)
- stop_socat_console(): stops socat/console_cmd subprocess

Change-Id: I79ddd83d12cc8111e05b5107359d6db8a8881d61
Spec: https://review.openstack.org/#/c/319505/
Related-Bug: #1553083
This commit is contained in:
Dao Cong Tien 2016-06-10 15:25:46 +07:00
parent 76726c6a3f
commit 22a80f77c5
6 changed files with 259 additions and 7 deletions

View File

@ -21,3 +21,4 @@ tftpd-hpa
xinetd
squashfs-tools
libvirt-dev
socat

View File

@ -16,3 +16,4 @@ tftp-server
xinetd
squashfs-tools
libvirt-devel
socat

View File

@ -632,11 +632,13 @@
# From ironic
#
# Path to serial console terminal program (string value)
# Path to serial console terminal program. Used only by
# Shell In A Box console. (string value)
#terminal = shellinaboxd
# Directory containing the terminal SSL cert(PEM) for serial
# console access (string value)
# console access. Used only by Shell In A Box console.
# (string value)
#terminal_cert_dir = <None>
# Directory for holding terminal pid files. If not specified,

View File

@ -21,10 +21,12 @@ from ironic.common.i18n import _
opts = [
cfg.StrOpt('terminal',
default='shellinaboxd',
help=_('Path to serial console terminal program')),
help=_('Path to serial console terminal program. Used only '
'by Shell In A Box console.')),
cfg.StrOpt('terminal_cert_dir',
help=_('Directory containing the terminal SSL cert(PEM) for '
'serial console access')),
help=_('Directory containing the terminal SSL cert (PEM) for '
'serial console access. Used only by Shell In A Box '
'console.')),
cfg.StrOpt('terminal_pid_dir',
help=_('Directory for holding terminal pid files. '
'If not specified, the temporary directory '

View File

@ -34,6 +34,7 @@ from oslo_utils import netutils
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common.i18n import _LE
from ironic.common.i18n import _LW
from ironic.common import utils
from ironic.conf import CONF
@ -110,8 +111,7 @@ def _stop_console(node_uuid):
raise exception.ConsoleError(message=msg)
else:
LOG.warning(_LW("Console process for node %s is not running "
"but pid file exists while trying to stop "
"shellinabox console."), node_uuid)
"but pid file exists."), node_uuid)
finally:
ironic_utils.unlink_without_raise(_get_console_pid_file(node_uuid))
@ -250,3 +250,123 @@ def stop_shellinabox_console(node_uuid):
except exception.NoConsolePid:
LOG.warning(_LW("No console pid found for node %s while trying to "
"stop shellinabox console."), node_uuid)
def get_socat_console_url(port):
"""Get a URL to access the console via socat.
:param port: the terminal port (integer) for the node
:return: an access URL to the socat console of the node
"""
console_host = CONF.my_ip
if netutils.is_valid_ipv6(console_host):
console_host = '[%s]' % console_host
return 'tcp://%(host)s:%(port)s' % {'host': console_host,
'port': port}
def start_socat_console(node_uuid, port, console_cmd):
"""Open the serial console for a node.
:param node_uuid: the uuid of the node
:param port: the terminal port for the node
:param console_cmd: the shell command that will be executed by socat to
establish console to the node
:raises ConsoleError: if the directory for the PID file or the PID file
cannot be created
:raises ConsoleSubprocessFailed: when invoking the subprocess failed
"""
# Make sure that the old console for this node is stopped.
# If no console is running, we may get exception NoConsolePid.
try:
_stop_console(node_uuid)
except exception.NoConsolePid:
pass
_ensure_console_pid_dir_exists()
pid_file = _get_console_pid_file(node_uuid)
# put together the command and arguments for invoking the console
args = ['socat']
args.append('-L%s' % pid_file)
console_host = CONF.my_ip
if netutils.is_valid_ipv6(console_host):
arg = 'TCP6-LISTEN:%(port)s,bind=[%(host)s],reuseaddr,fork'
else:
arg = 'TCP4-LISTEN:%(port)s,bind=%(host)s,reuseaddr,fork'
args.append(arg % {'host': console_host,
'port': port})
args.append('EXEC:"%s",pty,stderr' % console_cmd)
# run the command as a subprocess
try:
LOG.debug('Running subprocess: %s', ' '.join(args))
# Use pipe here to catch the error in case socat
# fails to start. Note that socat uses stdout as transferring
# data, so we only capture stderr for checking if it fails.
obj = subprocess.Popen(args, stderr=subprocess.PIPE)
except (OSError, ValueError) as e:
error = _("%(exec_error)s\n"
"Command: %(command)s") % {'exec_error': str(e),
'command': ' '.join(args)}
LOG.exception(_LE('Unable to start socat console'))
raise exception.ConsoleSubprocessFailed(error=error)
# NOTE: we need to check if socat fails to start here.
# If it starts successfully, it will run in non-daemon mode and
# will not return until the console session is stopped.
def _wait(node_uuid, popen_obj):
wait_state['returncode'] = popen_obj.poll()
# socat runs in non-daemon mode, so it should not return now
if wait_state['returncode'] is None:
# If the pid file is created and the process is running,
# we stop checking it periodically.
if (os.path.exists(pid_file) and
psutil.pid_exists(_get_console_pid(node_uuid))):
raise loopingcall.LoopingCallDone()
else:
# socat returned, it failed to start.
# We get the error (out should be None in this case).
(_out, err) = popen_obj.communicate()
wait_state['errstr'] = _(
"Command: %(command)s.\n"
"Exit code: %(return_code)s.\n"
"Stderr: %(error)r") % {
'command': ' '.join(args),
'return_code': wait_state['returncode'],
'error': err}
LOG.error(wait_state['errstr'])
raise loopingcall.LoopingCallDone()
if time.time() > expiration:
wait_state['errstr'] = (_("Timeout while waiting for console "
"subprocess to start for node %s.") %
node_uuid)
LOG.error(wait_state['errstr'])
raise loopingcall.LoopingCallDone()
wait_state = {'returncode': None, 'errstr': ''}
expiration = time.time() + CONF.console.subprocess_timeout
timer = loopingcall.FixedIntervalLoopingCall(_wait, node_uuid, obj)
timer.start(interval=CONF.console.subprocess_checking_interval).wait()
if wait_state['errstr']:
raise exception.ConsoleSubprocessFailed(error=wait_state['errstr'])
def stop_socat_console(node_uuid):
"""Close the serial console for a node.
:param node_uuid: the UUID of the node
:raise ConsoleError: if unable to stop the console process
"""
try:
_stop_console(node_uuid)
except exception.NoConsolePid:
LOG.warning(_LW("No console pid found for node %s while trying to "
"stop socat console."), node_uuid)

View File

@ -407,3 +407,129 @@ class ConsoleUtilsTestCase(db_base.DbTestCase):
console_utils.stop_shellinabox_console(self.info['uuid'])
mock_stop.assert_called_once_with(self.info['uuid'])
def test_get_socat_console_url_tcp(self):
self.config(my_ip="10.0.0.1")
url = console_utils.get_socat_console_url(self.info['port'])
self.assertEqual("tcp://10.0.0.1:%s" % self.info['port'], url)
def test_get_socat_console_url_tcp6(self):
self.config(my_ip='::1')
url = console_utils.get_socat_console_url(self.info['port'])
self.assertEqual("tcp://[::1]:%s" % self.info['port'], url)
@mock.patch.object(os.path, 'exists', autospec=True)
@mock.patch.object(subprocess, 'Popen', autospec=True)
@mock.patch.object(psutil, 'pid_exists', autospec=True)
@mock.patch.object(console_utils, '_get_console_pid', autospec=True)
@mock.patch.object(console_utils, '_ensure_console_pid_dir_exists',
autospec=True)
@mock.patch.object(console_utils, '_stop_console', autospec=True)
def test_start_socat_console(self, mock_stop,
mock_dir_exists,
mock_get_pid,
mock_pid_exists,
mock_popen,
mock_path_exists):
mock_popen.return_value.pid = 23456
mock_popen.return_value.poll.return_value = None
mock_popen.return_value.communicate.return_value = (None, None)
mock_get_pid.return_value = 23456
mock_path_exists.return_value = True
console_utils.start_socat_console(self.info['uuid'],
self.info['port'],
'ls&')
mock_stop.assert_called_once_with(self.info['uuid'])
mock_dir_exists.assert_called_once_with()
mock_get_pid.assert_called_with(self.info['uuid'])
mock_path_exists.assert_called_with(mock.ANY)
mock_popen.assert_called_once_with(mock.ANY, stderr=subprocess.PIPE)
@mock.patch.object(os.path, 'exists', autospec=True)
@mock.patch.object(subprocess, 'Popen', autospec=True)
@mock.patch.object(psutil, 'pid_exists', autospec=True)
@mock.patch.object(console_utils, '_get_console_pid', autospec=True)
@mock.patch.object(console_utils, '_ensure_console_pid_dir_exists',
autospec=True)
@mock.patch.object(console_utils, '_stop_console', autospec=True)
def test_start_socat_console_nopid(self, mock_stop,
mock_dir_exists,
mock_get_pid,
mock_pid_exists,
mock_popen,
mock_path_exists):
# no existing PID file before starting
mock_stop.side_effect = exception.NoConsolePid('/tmp/blah')
mock_popen.return_value.pid = 23456
mock_popen.return_value.poll.return_value = None
mock_popen.return_value.communicate.return_value = (None, None)
mock_get_pid.return_value = 23456
mock_path_exists.return_value = True
console_utils.start_socat_console(self.info['uuid'],
self.info['port'],
'ls&')
mock_stop.assert_called_once_with(self.info['uuid'])
mock_dir_exists.assert_called_once_with()
mock_get_pid.assert_called_with(self.info['uuid'])
mock_path_exists.assert_called_with(mock.ANY)
mock_popen.assert_called_once_with(mock.ANY, stderr=subprocess.PIPE)
@mock.patch.object(subprocess, 'Popen', autospec=True)
@mock.patch.object(console_utils, '_ensure_console_pid_dir_exists',
autospec=True)
@mock.patch.object(console_utils, '_stop_console', autospec=True)
def test_start_socat_console_fail(self, mock_stop, mock_dir_exists,
mock_popen):
mock_popen.side_effect = OSError()
mock_popen.return_value.pid = 23456
mock_popen.return_value.poll.return_value = 1
mock_popen.return_value.communicate.return_value = (None, 'error')
self.assertRaises(exception.ConsoleSubprocessFailed,
console_utils.start_socat_console,
self.info['uuid'],
self.info['port'],
'ls&')
mock_stop.assert_called_once_with(self.info['uuid'])
mock_dir_exists.assert_called_once_with()
mock_popen.assert_called_once_with(mock.ANY, stderr=subprocess.PIPE)
@mock.patch.object(subprocess, 'Popen', autospec=True)
@mock.patch.object(console_utils, '_ensure_console_pid_dir_exists',
autospec=True)
@mock.patch.object(console_utils, '_stop_console', autospec=True)
def test_start_socat_console_fail_nopiddir(self, mock_stop,
mock_dir_exists,
mock_popen):
mock_dir_exists.side_effect = exception.ConsoleError(message='fail')
self.assertRaises(exception.ConsoleError,
console_utils.start_socat_console,
self.info['uuid'],
self.info['port'],
'ls&')
mock_stop.assert_called_once_with(self.info['uuid'])
mock_dir_exists.assert_called_once_with()
mock_popen.assert_not_called()
@mock.patch.object(console_utils, '_stop_console', autospec=True)
def test_stop_socat_console(self, mock_stop):
console_utils.stop_socat_console(self.info['uuid'])
mock_stop.assert_called_once_with(self.info['uuid'])
@mock.patch.object(console_utils.LOG, 'warning', autospec=True)
@mock.patch.object(console_utils, '_stop_console', autospec=True)
def test_stop_socat_console_fail_nopid(self, mock_stop, mock_log_warning):
mock_stop.side_effect = exception.NoConsolePid('/tmp/blah')
console_utils.stop_socat_console(self.info['uuid'])
mock_stop.assert_called_once_with(self.info['uuid'])
# LOG.warning() is called when _stop_console() raises NoConsolePid
self.assertTrue(mock_log_warning.called)