From 22a80f77c578b4c10298ee16daf351fd5bfc25d4 Mon Sep 17 00:00:00 2001
From: Dao Cong Tien <tiendc@vn.fujitsu.com>
Date: Fri, 10 Jun 2016 15:25:46 +0700
Subject: [PATCH] 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
---
 devstack/files/debs/ironic                    |   1 +
 devstack/files/rpms/ironic                    |   1 +
 etc/ironic/ironic.conf.sample                 |   6 +-
 ironic/conf/console.py                        |   8 +-
 ironic/drivers/modules/console_utils.py       | 124 ++++++++++++++++-
 .../drivers/modules/test_console_utils.py     | 126 ++++++++++++++++++
 6 files changed, 259 insertions(+), 7 deletions(-)

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 = <None>
 
 # 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)