diff --git a/devstack/files/debs/ironic b/devstack/files/debs/ironic index ce4ab42b40..4cb130e7dc 100644 --- a/devstack/files/debs/ironic +++ b/devstack/files/debs/ironic @@ -21,3 +21,4 @@ tftpd-hpa xinetd squashfs-tools libvirt-dev +socat diff --git a/devstack/files/rpms/ironic b/devstack/files/rpms/ironic index ce90401ae8..9bbf30e88f 100644 --- a/devstack/files/rpms/ironic +++ b/devstack/files/rpms/ironic @@ -16,3 +16,4 @@ tftp-server xinetd squashfs-tools libvirt-devel +socat diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 796358e9fd..f8c02390ca 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -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 = # Directory for holding terminal pid files. If not specified, diff --git a/ironic/conf/console.py b/ironic/conf/console.py index 692c0df6bb..a6df1b5616 100644 --- a/ironic/conf/console.py +++ b/ironic/conf/console.py @@ -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 ' diff --git a/ironic/drivers/modules/console_utils.py b/ironic/drivers/modules/console_utils.py index 6a54d05696..e7dde67098 100644 --- a/ironic/drivers/modules/console_utils.py +++ b/ironic/drivers/modules/console_utils.py @@ -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) diff --git a/ironic/tests/unit/drivers/modules/test_console_utils.py b/ironic/tests/unit/drivers/modules/test_console_utils.py index 66b79a52c6..d54fb6bf62 100644 --- a/ironic/tests/unit/drivers/modules/test_console_utils.py +++ b/ironic/tests/unit/drivers/modules/test_console_utils.py @@ -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)