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:
parent
76726c6a3f
commit
22a80f77c5
@ -21,3 +21,4 @@ tftpd-hpa
|
||||
xinetd
|
||||
squashfs-tools
|
||||
libvirt-dev
|
||||
socat
|
||||
|
@ -16,3 +16,4 @@ tftp-server
|
||||
xinetd
|
||||
squashfs-tools
|
||||
libvirt-devel
|
||||
socat
|
||||
|
@ -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,
|
||||
|
@ -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 '
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user