From 5126dafe59c8d9a2cf1c953cfbcaa5cc8ea5af39 Mon Sep 17 00:00:00 2001 From: Harshada Mangesh Kakad Date: Mon, 3 Nov 2014 23:19:03 -0800 Subject: [PATCH] Add serial console feature to seamicro driver Implements a console driver -- "ShellinaboxConsole" -- that uses telnet+shellinabox to connect to serial console of physical servers configured within the Seamicro Fabric Compute system. SeaMicro System provides telnet facility to connect to any of it's physical server's serial console. Port to which we telnet depends on server-id. Change-Id: I2e60f6402a984dbbb6e0f749729c5a7006310076 Implements: blueprint seamicro-serial-console --- ironic/drivers/fake.py | 1 + ironic/drivers/modules/seamicro.py | 114 ++++++++++++++++++++++++- ironic/drivers/pxe.py | 1 + ironic/tests/conductor/test_manager.py | 4 +- ironic/tests/drivers/test_seamicro.py | 94 +++++++++++++++++++- 5 files changed, 210 insertions(+), 4 deletions(-) diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index 341eafd6f3..b9f8ab9f50 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -109,6 +109,7 @@ class FakeSeaMicroDriver(base.BaseDriver): self.deploy = fake.FakeDeploy() self.management = seamicro.Management() self.vendor = seamicro.VendorPassthru() + self.console = seamicro.ShellinaboxConsole() class FakeAgentDriver(base.BaseDriver): diff --git a/ironic/drivers/modules/seamicro.py b/ironic/drivers/modules/seamicro.py index 21a217e180..655dea331c 100644 --- a/ironic/drivers/modules/seamicro.py +++ b/ironic/drivers/modules/seamicro.py @@ -18,9 +18,12 @@ python-seamicroclient. Provides vendor passthru methods for SeaMicro specific functionality. """ +import os +import re from oslo.config import cfg from oslo.utils import importutils +from six.moves.urllib import parse as urlparse from ironic.common import boot_devices from ironic.common import exception @@ -30,6 +33,7 @@ from ironic.common.i18n import _LW from ironic.common import states from ironic.conductor import task_manager from ironic.drivers import base +from ironic.drivers.modules import console_utils from ironic.openstack.common import log as logging from ironic.openstack.common import loopingcall @@ -72,6 +76,11 @@ OPTIONAL_PROPERTIES = { } COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES) +CONSOLE_PROPERTIES = { + 'seamicro_terminal_port': _("node's UDP port to connect to. " + "Only required for console access.") +} +PORT_BASE = 2000 def _get_client(*args, **kwargs): @@ -99,6 +108,7 @@ def _parse_driver_info(node): :param node: An Ironic node object. :returns: SeaMicro driver info. :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if required parameter are invalid. """ info = node.driver_info or {} @@ -113,13 +123,35 @@ def _parse_driver_info(node): password = info.get('seamicro_password') server_id = info.get('seamicro_server_id') api_version = info.get('seamicro_api_version', "2") + port = info.get('seamicro_terminal_port') + + if port: + try: + port = int(port) + except ValueError: + raise exception.InvalidParameterValue(_( + "SeaMicro terminal port is not an integer.")) + + r = re.compile(r"(^[0-9]+)/([0-9]+$)") + if not r.match(server_id): + raise exception.InvalidParameterValue(_( + "Invalid 'seamicro_server_id' parameter in node's " + "driver_info. Expected format of 'seamicro_server_id' " + "is /")) + + url = urlparse.urlparse(api_endpoint) + if (not (url.scheme == "http") or not url.netloc): + raise exception.InvalidParameterValue(_( + "Invalid 'seamicro_api_endpoint' parameter in node's " + "driver_info.")) res = {'username': username, 'password': password, 'api_endpoint': api_endpoint, 'server_id': server_id, 'api_version': api_version, - 'uuid': node.uuid} + 'uuid': node.uuid, + 'port': port} return res @@ -329,6 +361,12 @@ def _create_volume(driver_info, volume_size): least_used_pool) +def get_telnet_port(driver_info): + """Get SeaMicro telnet port to listen.""" + server_id = int(driver_info['server_id'].split("/")[0]) + return PORT_BASE + (10 * server_id) + + class Power(base.PowerInterface): """SeaMicro Power Interface. @@ -572,3 +610,77 @@ class Management(base.ManagementInterface): """ raise NotImplementedError() + + +class ShellinaboxConsole(base.ConsoleInterface): + """A ConsoleInterface that uses telnet and shellinabox.""" + + def get_properties(self): + d = COMMON_PROPERTIES.copy() + d.update(CONSOLE_PROPERTIES) + return d + + def validate(self, task): + """Validate the Node console info. + + :param task: a task from TaskManager. + :raises: MissingParameterValue if required seamicro parameters are + missing + :raises: InvalidParameterValue if required parameter are invalid. + """ + driver_info = _parse_driver_info(task.node) + if not driver_info['port']: + raise exception.MissingParameterValue(_( + "Missing 'seamicro_terminal_port' parameter in node's " + "driver_info")) + + def start_console(self, task): + """Start a remote console for the node. + + :param task: a task from TaskManager + :raises: MissingParameterValue if required seamicro parameters are + missing + :raises: ConsoleError if the directory for the PID file cannot be + created + :raises: ConsoleSubprocessFailed when invoking the subprocess failed + :raises: InvalidParameterValue if required parameter are invalid. + """ + + driver_info = _parse_driver_info(task.node) + telnet_port = get_telnet_port(driver_info) + chassis_ip = urlparse.urlparse(driver_info['api_endpoint']).netloc + + seamicro_cmd = ("/:%(uid)s:%(gid)s:HOME:telnet %(chassis)s %(port)s" + % {'uid': os.getuid(), + 'gid': os.getgid(), + 'chassis': chassis_ip, + 'port': telnet_port}) + + console_utils.start_shellinabox_console(driver_info['uuid'], + driver_info['port'], + seamicro_cmd) + + def stop_console(self, task): + """Stop the remote console session for the node. + + :param task: a task from TaskManager + :raises: MissingParameterValue if required seamicro parameters are + missing + :raises: ConsoleError if unable to stop the console + :raises: InvalidParameterValue if required parameter are invalid. + """ + + driver_info = _parse_driver_info(task.node) + console_utils.stop_shellinabox_console(driver_info['uuid']) + + def get_console(self, task): + """Get the type and connection information about the console. + + :raises: MissingParameterValue if required seamicro parameters are + missing + :raises: InvalidParameterValue if required parameter are invalid. + """ + + driver_info = _parse_driver_info(task.node) + url = console_utils.get_shellinabox_console_url(driver_info['port']) + return {'type': 'shellinabox', 'url': url} diff --git a/ironic/drivers/pxe.py b/ironic/drivers/pxe.py index 1e454b5dfc..130df0d9a2 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -118,6 +118,7 @@ class PXEAndSeaMicroDriver(base.BaseDriver): 'attach_volume': self.seamicro_vendor, 'set_node_vlan_id': self.seamicro_vendor} self.vendor = utils.MixinVendorInterface(self.mapping) + self.console = seamicro.ShellinaboxConsole() class PXEAndIBootDriver(base.BaseDriver): diff --git a/ironic/tests/conductor/test_manager.py b/ironic/tests/conductor/test_manager.py index e7685ba183..7b5b9f2c95 100644 --- a/ironic/tests/conductor/test_manager.py +++ b/ironic/tests/conductor/test_manager.py @@ -2399,7 +2399,7 @@ class ManagerTestProperties(tests_db_base.DbTestCase): def test_driver_properties_fake_seamicro(self): expected = ['seamicro_api_endpoint', 'seamicro_password', 'seamicro_server_id', 'seamicro_username', - 'seamicro_api_version'] + 'seamicro_api_version', 'seamicro_terminal_port'] self._check_driver_properties("fake_seamicro", expected) def test_driver_properties_fake_snmp(self): @@ -2434,7 +2434,7 @@ class ManagerTestProperties(tests_db_base.DbTestCase): expected = ['pxe_deploy_kernel', 'pxe_deploy_ramdisk', 'seamicro_api_endpoint', 'seamicro_password', 'seamicro_server_id', 'seamicro_username', - 'seamicro_api_version'] + 'seamicro_api_version', 'seamicro_terminal_port'] self._check_driver_properties("pxe_seamicro", expected) def test_driver_properties_pxe_snmp(self): diff --git a/ironic/tests/drivers/test_seamicro.py b/ironic/tests/drivers/test_seamicro.py index c2947cc42b..266ed462d9 100644 --- a/ironic/tests/drivers/test_seamicro.py +++ b/ironic/tests/drivers/test_seamicro.py @@ -24,6 +24,7 @@ from ironic.common import exception from ironic.common import states from ironic.common import utils from ironic.conductor import task_manager +from ironic.drivers.modules import console_utils from ironic.drivers.modules import seamicro from ironic.tests.conductor import utils as mgr_utils from ironic.tests.db import base as db_base @@ -291,7 +292,14 @@ class SeaMicroPowerDriverTestCase(db_base.DbTestCase): expected = seamicro.COMMON_PROPERTIES with task_manager.acquire(self.context, self.node['uuid'], shared=True) as task: - self.assertEqual(expected, task.driver.get_properties()) + self.assertEqual(expected, task.driver.power.get_properties()) + + expected = (list(seamicro.COMMON_PROPERTIES) + + list(seamicro.CONSOLE_PROPERTIES)) + console_properties = task.driver.console.get_properties().keys() + self.assertEqual(sorted(expected), sorted(console_properties)) + self.assertEqual(sorted(expected), + sorted(task.driver.get_properties().keys())) def test_vendor_routes(self): expected = ['set_node_vlan_id', 'attach_volume'] @@ -601,3 +609,87 @@ class SeaMicroPowerDriverTestCase(db_base.DbTestCase): with task_manager.acquire(self.context, node.uuid) as task: self.assertRaises(exception.MissingParameterValue, task.driver.management.validate, task) + + +class SeaMicroDriverTestCase(db_base.DbTestCase): + + def setUp(self): + super(SeaMicroDriverTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_seamicro') + self.driver = driver_factory.get_driver('fake_seamicro') + self.node = obj_utils.create_test_node(self.context, + driver='fake_seamicro', + driver_info=INFO_DICT) + self.get_server_patcher = mock.patch.object(seamicro, '_get_server') + + self.get_server_mock = None + self.Server = Fake_Server + self.Volume = Fake_Volume + self.info = seamicro._parse_driver_info(self.node) + + @mock.patch.object(console_utils, 'start_shellinabox_console') + def test_start_console(self, mock_exec): + mock_exec.return_value = None + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.driver.console.start_console(task) + + mock_exec.assert_called_once_with(self.info['uuid'], + self.info['port'], + mock.ANY) + + @mock.patch.object(console_utils, 'start_shellinabox_console') + def test_start_console_fail(self, mock_exec): + mock_exec.side_effect = exception.ConsoleSubprocessFailed( + error='error') + + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.assertRaises(exception.ConsoleSubprocessFailed, + self.driver.console.start_console, + task) + + @mock.patch.object(console_utils, 'stop_shellinabox_console') + def test_stop_console(self, mock_exec): + mock_exec.return_value = None + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.driver.console.stop_console(task) + + mock_exec.assert_called_once_with(self.info['uuid']) + + @mock.patch.object(console_utils, 'stop_shellinabox_console') + def test_stop_console_fail(self, mock_stop): + mock_stop.side_effect = exception.ConsoleError() + + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.assertRaises(exception.ConsoleError, + self.driver.console.stop_console, + task) + + mock_stop.assert_called_once_with(self.node.uuid) + + @mock.patch.object(console_utils, 'start_shellinabox_console') + def test_start_console_fail_nodir(self, mock_exec): + mock_exec.side_effect = exception.ConsoleError() + + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.assertRaises(exception.ConsoleError, + self.driver.console.start_console, + task) + mock_exec.assert_called_once_with(self.node.uuid, mock.ANY, mock.ANY) + + @mock.patch.object(console_utils, 'get_shellinabox_console_url') + def test_get_console(self, mock_exec): + url = 'http://localhost:4201' + mock_exec.return_value = url + expected = {'type': 'shellinabox', 'url': url} + + with task_manager.acquire(self.context, + self.node.uuid) as task: + console_info = self.driver.console.get_console(task) + + self.assertEqual(expected, console_info) + mock_exec.assert_called_once_with(self.info['port'])