diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index a23f2b99af..b88501d0cf 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -868,6 +868,20 @@ #topics=notifications +[seamicro] + +# +# Options defined in ironic.drivers.modules.seamicro +# + +# Maximum retries for SeaMicro operations (integer value) +#max_retry=3 + +# Seconds to wait for power action to be completed (integer +# value) +#action_timeout=10 + + [ssh] # diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index 73215977a8..8fd0897892 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -22,6 +22,7 @@ from ironic.drivers.modules import fake from ironic.drivers.modules import ipminative from ironic.drivers.modules import ipmitool from ironic.drivers.modules import pxe +from ironic.drivers.modules import seamicro from ironic.drivers.modules import ssh @@ -72,3 +73,15 @@ class FakeIPMINativeDriver(base.BaseDriver): self.power = ipminative.NativeIPMIPower() self.deploy = fake.FakeDeploy() self.vendor = ipminative.VendorPassthru() + + +class FakeSeaMicroDriver(base.BaseDriver): + """Fake SeaMicro driver.""" + + def __init__(self): + self.power = seamicro.Power() + self.deploy = fake.FakeDeploy() + self.rescue = self.deploy + a = fake.FakeVendorA() + b = fake.FakeVendorB() + self.vendor = fake.MultipleVendorInterface(a, b) diff --git a/ironic/drivers/modules/seamicro.py b/ironic/drivers/modules/seamicro.py new file mode 100644 index 0000000000..871860ce36 --- /dev/null +++ b/ironic/drivers/modules/seamicro.py @@ -0,0 +1,309 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Ironic SeaMicro interfaces. + +Provides basic power control of servers in SeaMicro chassis via +python-seamicroclient. +""" + +from oslo.config import cfg +from seamicroclient import client as seamicro_client +from seamicroclient import exceptions as seamicro_client_exception + + +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.openstack.common import log as logging +from ironic.openstack.common import loopingcall + +opts = [ + cfg.IntOpt('max_retry', + default=3, + help='Maximum retries for SeaMicro operations'), + cfg.IntOpt('action_timeout', + default=10, + help='Seconds to wait for power action to be completed') +] + +CONF = cfg.CONF +opt_group = cfg.OptGroup(name='seamicro', + title='Options for the seamicro power driver') +CONF.register_group(opt_group) +CONF.register_opts(opts, opt_group) + +LOG = logging.getLogger(__name__) + + +def _get_client(*args, **kwargs): + """Creates the python-seamicro_client + + :param kwargs: A dict of keyword arguments to be passed to the method, + which should contain: 'username', 'password', + 'auth_url', 'api_version' parameters. + :returns: SeaMicro API client. + """ + + cl_kwargs = {'username': kwargs['username'], + 'password': kwargs['password'], + 'auth_url': kwargs['api_endpoint']} + return seamicro_client.Client(kwargs['api_version'], **cl_kwargs) + + +def _parse_driver_info(node): + """Parses and creates seamicro driver info + + :param node: An Ironic node object. + :returns: SeaMicro driver info. + """ + + info = node.get('driver_info', {}) + api_endpoint = info.get('seamicro_api_endpoint', None) + username = info.get('seamicro_username', None) + password = info.get('seamicro_password', None) + server_id = info.get('seamicro_server_id', None) + api_version = info.get('seamicro_api_version', "2") + + if not api_endpoint: + raise exception.InvalidParameterValue(_( + "SeaMicro driver requires api_endpoint be set")) + + if not username or not password: + raise exception.InvalidParameterValue(_( + "SeaMicro driver requires both username and password be set")) + + if not server_id: + raise exception.InvalidParameterValue(_( + "SeaMicro driver requires server_id be set")) + + res = {'username': username, + 'password': password, + 'api_endpoint': api_endpoint, + 'server_id': server_id, + 'api_version': api_version, + 'uuid': node.get('uuid')} + + return res + + +def _get_server(driver_info): + """Get server from server_id.""" + + s_client = _get_client(**driver_info) + return s_client.servers.get(driver_info['server_id']) + + +def _get_power_status(node): + """Get current power state of this node + + :param node: Ironic node one of :class:`ironic.db.models.Node` + :returns: Power state of the given node + """ + + seamicro_info = _parse_driver_info(node) + try: + server = _get_server(seamicro_info) + if not hasattr(server, 'active') or server.active is None: + return states.ERROR + if not server.active: + return states.POWER_OFF + elif server.active: + return states.POWER_ON + + except seamicro_client_exception.NotFound: + raise exception.NodeNotFound(node=seamicro_info['uuid']) + except seamicro_client_exception.ClientException as ex: + LOG.error(_("SeaMicro client exception %(msg)s for node %(uuid)s"), + {'msg': ex.message, 'uuid': seamicro_info['uuid']}) + raise exception.ServiceUnavailable(message=ex.message) + + +def _power_on(node, timeout=None): + """Power ON this node + + :param node: An Ironic node object. + :param timeout: Time in seconds to wait till power on is complete. + :returns: Power state of the given node. + """ + if timeout is None: + timeout = CONF.seamicro.action_timeout + state = [None] + retries = [0] + seamicro_info = _parse_driver_info(node) + server = _get_server(seamicro_info) + + def _wait_for_power_on(state, retries): + """Called at an interval until the node is powered on.""" + + state[0] = _get_power_status(node) + if state[0] == states.POWER_ON: + raise loopingcall.LoopingCallDone() + + if retries[0] > CONF.seamicro.max_retry: + state[0] = states.ERROR + raise loopingcall.LoopingCallDone() + try: + retries[0] += 1 + server.power_on() + except seamicro_client_exception.ClientException: + LOG.warning(_("Power-on failed for node %s."), + seamicro_info['uuid']) + + timer = loopingcall.FixedIntervalLoopingCall(_wait_for_power_on, + state, retries) + timer.start(interval=timeout).wait() + return state[0] + + +def _power_off(node, timeout=None): + """Power OFF this node + + :param node: Ironic node one of :class:`ironic.db.models.Node` + :param timeout: Time in seconds to wait till power off is compelete + :returns: Power state of the given node + """ + if timeout is None: + timeout = CONF.seamicro.action_timeout + state = [None] + retries = [0] + seamicro_info = _parse_driver_info(node) + server = _get_server(seamicro_info) + + def _wait_for_power_off(state, retries): + """Called at an interval until the node is powered off.""" + + state[0] = _get_power_status(node) + if state[0] == states.POWER_OFF: + raise loopingcall.LoopingCallDone() + + if retries[0] > CONF.seamicro.max_retry: + state[0] = states.ERROR + raise loopingcall.LoopingCallDone() + try: + retries[0] += 1 + server.power_off() + except seamicro_client_exception.ClientException: + LOG.warning(_("Power-off failed for node %s."), + seamicro_info['uuid']) + + timer = loopingcall.FixedIntervalLoopingCall(_wait_for_power_off, + state, retries) + timer.start(interval=timeout).wait() + return state[0] + + +def _reboot(node, timeout=None): + """Reboot this node + :param node: Ironic node one of :class:`ironic.db.models.Node` + :param timeout: Time in seconds to wait till reboot is compelete + :returns: Power state of the given node + """ + if timeout is None: + timeout = CONF.seamicro.action_timeout + state = [None] + retries = [0] + seamicro_info = _parse_driver_info(node) + server = _get_server(seamicro_info) + + def _wait_for_reboot(state, retries): + """Called at an interval until the node is rebooted successfully.""" + + state[0] = _get_power_status(node) + if state[0] == states.POWER_ON: + raise loopingcall.LoopingCallDone() + + if retries[0] > CONF.seamicro.max_retry: + state[0] = states.ERROR + raise loopingcall.LoopingCallDone() + + try: + retries[0] += 1 + server.reset() + except seamicro_client_exception.ClientException: + LOG.warning(_("Reboot failed for node %s."), + seamicro_info['uuid']) + + timer = loopingcall.FixedIntervalLoopingCall(_wait_for_reboot, + state, retries) + server.reset() + timer.start(interval=timeout).wait() + return state[0] + + +class Power(base.PowerInterface): + """SeaMicro Power Interface. + + This PowerInterface class provides a mechanism for controlling the power + state of servers in a seamicro chassis. + """ + + def validate(self, node): + """Check that node 'driver_info' is valid. + + Check that node 'driver_info' contains the required fields. + + :param node: Single node object. + :raises: InvalidParameterValue + """ + _parse_driver_info(node) + + def get_power_state(self, task, node): + """Get the current power state. + + Poll the host for the current power state of the node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + + :returns: power state. One of :class:`ironic.common.states`. + """ + return _get_power_status(node) + + @task_manager.require_exclusive_lock + def set_power_state(self, task, node, pstate): + """Turn the power on or off. + + Set the power state of a node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + :param pstate: Either POWER_ON or POWER_OFF from :class: + `ironic.common.states`. + """ + + if pstate == states.POWER_ON: + state = _power_on(node) + elif pstate == states.POWER_OFF: + state = _power_off(node) + else: + raise exception.IronicException(_( + "set_power_state called with invalid power state.")) + + if state != pstate: + raise exception.PowerStateFailure(pstate=pstate) + + @task_manager.require_exclusive_lock + def reboot(self, task, node): + """Cycles the power to a node. + + :param task: a TaskManager instance. + :param node: An Ironic node object. + + """ + state = _reboot(node) + + if state != states.POWER_ON: + raise exception.PowerStateFailure(pstate=states.POWER_ON) diff --git a/ironic/drivers/pxe.py b/ironic/drivers/pxe.py index e39efb8685..2ffeaedd44 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -21,6 +21,7 @@ from ironic.drivers import base from ironic.drivers.modules import ipminative from ironic.drivers.modules import ipmitool from ironic.drivers.modules import pxe +from ironic.drivers.modules import seamicro from ironic.drivers.modules import ssh @@ -75,3 +76,21 @@ class PXEAndIPMINativeDriver(base.BaseDriver): self.deploy = pxe.PXEDeploy() self.rescue = self.deploy self.vendor = pxe.VendorPassthru() + + +class PXEAndSeaMicroDriver(base.BaseDriver): + """PXE + SeaMicro driver. + + This driver implements the `core` functionality, combining + :class:ironic.drivers.modules.seamicro.Power for power + on/off and reboot with + :class:ironic.driver.modules.pxe.PXE for image deployment. + Implementations are in those respective classes; + this class is merely the glue between them. + """ + + def __init__(self): + self.power = seamicro.Power() + self.deploy = pxe.PXEDeploy() + self.rescue = self.deploy + self.vendor = pxe.VendorPassthru() diff --git a/ironic/tests/db/utils.py b/ironic/tests/db/utils.py index e498e7cc08..96d2f83582 100644 --- a/ironic/tests/db/utils.py +++ b/ironic/tests/db/utils.py @@ -45,6 +45,15 @@ def get_test_pxe_info(): } +def get_test_seamicro_info(): + return { + "seamicro_api_endpoint": "http://1.2.3.4", + "seamicro_username": "admin", + "seamicro_password": "fake", + "seamicro_server_id": "0/0", + } + + def get_test_node(**kw): properties = { "cpu_arch": "x86_64", diff --git a/ironic/tests/drivers/test_seamicro.py b/ironic/tests/drivers/test_seamicro.py new file mode 100644 index 0000000000..e20d3da1b5 --- /dev/null +++ b/ironic/tests/drivers/test_seamicro.py @@ -0,0 +1,269 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Test class for Ironic SeaMicro driver.""" + +import mock + +from ironic.common import driver_factory +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.db import api as dbapi +from ironic.drivers.modules import seamicro +from ironic.tests import base +from ironic.tests.conductor import utils as mgr_utils +from ironic.tests.db import base as db_base +from ironic.tests.db import utils as db_utils + +INFO_DICT = db_utils.get_test_seamicro_info() + + +class Fake_Server(): + def __init__(self, active=False, *args, **kwargs): + self.active = active + + def power_on(self): + self.active = True + + def power_off(self, force=False): + self.active = False + + def reset(self): + self.active = True + + +class SeaMicroValidateParametersTestCase(base.TestCase): + + def test__parse_driver_info_good(self): + # make sure we get back the expected things + node = db_utils.get_test_node(driver='fake_seamicro', + driver_info=INFO_DICT) + info = seamicro._parse_driver_info(node) + self.assertIsNotNone(info.get('api_endpoint')) + self.assertIsNotNone(info.get('username')) + self.assertIsNotNone(info.get('password')) + self.assertIsNotNone(info.get('server_id')) + self.assertIsNotNone(info.get('uuid')) + + def test__parse_driver_info_missing_api_endpoint(self): + # make sure error is raised when info is missing + info = dict(INFO_DICT) + del info['seamicro_api_endpoint'] + node = db_utils.get_test_node(driver_info=info) + self.assertRaises(exception.InvalidParameterValue, + seamicro._parse_driver_info, + node) + + def test__parse_driver_info_missing_username(self): + # make sure error is raised when info is missing + info = dict(INFO_DICT) + del info['seamicro_username'] + node = db_utils.get_test_node(driver_info=info) + self.assertRaises(exception.InvalidParameterValue, + seamicro._parse_driver_info, + node) + + def test__parse_driver_info_missing_password(self): + # make sure error is raised when info is missing + info = dict(INFO_DICT) + del info['seamicro_password'] + node = db_utils.get_test_node(driver_info=info) + self.assertRaises(exception.InvalidParameterValue, + seamicro._parse_driver_info, + node) + + def test__parse_driver_info_missing_server_id(self): + # make sure error is raised when info is missing + info = dict(INFO_DICT) + del info['seamicro_server_id'] + node = db_utils.get_test_node(driver_info=info) + self.assertRaises(exception.InvalidParameterValue, + seamicro._parse_driver_info, + node) + + +class SeaMicroPrivateMethodsTestCase(base.TestCase): + + def setUp(self): + super(SeaMicroPrivateMethodsTestCase, self).setUp() + self.node = db_utils.get_test_node(driver='fake_seamicro', + driver_info=INFO_DICT) + + self.Server = Fake_Server + + @mock.patch.object(seamicro, "_get_server") + def test__get_power_status_on(self, mock_get_server): + mock_get_server.return_value = self.Server(active=True) + pstate = seamicro._get_power_status(self.node) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__get_power_status_off(self, mock_get_server): + mock_get_server.return_value = self.Server(active=False) + pstate = seamicro._get_power_status(self.node) + self.assertEqual(states.POWER_OFF, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__get_power_status_error(self, mock_get_server): + mock_get_server.return_value = self.Server(active=None) + pstate = seamicro._get_power_status(self.node) + self.assertEqual(states.ERROR, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__power_on_good(self, mock_get_server): + mock_get_server.return_value = self.Server(active=False) + pstate = seamicro._power_on(self.node, timeout=2) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__power_on_fail(self, mock_get_server): + def fake_power_on(): + return + + server = self.Server(active=False) + server.power_on = fake_power_on + mock_get_server.return_value = server + pstate = seamicro._power_on(self.node, timeout=2) + self.assertEqual(states.ERROR, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__power_off_good(self, mock_get_server): + mock_get_server.return_value = self.Server(active=True) + pstate = seamicro._power_off(self.node, timeout=2) + self.assertEqual(states.POWER_OFF, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__power_off_fail(self, mock_get_server): + def fake_power_off(): + return + server = self.Server(active=True) + server.power_off = fake_power_off + mock_get_server.return_value = server + pstate = seamicro._power_off(self.node, timeout=2) + self.assertEqual(states.ERROR, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__reboot_good(self, mock_get_server): + mock_get_server.return_value = self.Server(active=True) + pstate = seamicro._reboot(self.node, timeout=2) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch.object(seamicro, "_get_server") + def test__reboot_fail(self, mock_get_server): + def fake_reboot(): + return + server = self.Server(active=False) + server.reset = fake_reboot + mock_get_server.return_value = server + pstate = seamicro._reboot(self.node, timeout=2) + self.assertEqual(states.ERROR, pstate) + + +class SeaMicroPowerDriverTestCase(db_base.DbTestCase): + + def setUp(self): + super(SeaMicroPowerDriverTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_seamicro') + self.driver = driver_factory.get_driver('fake_seamicro') + self.node = db_utils.get_test_node(driver='fake_seamicro', + driver_info=INFO_DICT) + self.dbapi = dbapi.get_instance() + self.dbapi.create_node(self.node) + self.parse_drv_info_patcher = mock.patch.object(seamicro, + '_parse_driver_info') + self.parse_drv_info_mock = None + self.get_server_patcher = mock.patch.object(seamicro, '_get_server') + + self.get_server_mock = None + self.Server = Fake_Server + + @mock.patch.object(seamicro, '_reboot') + def test_reboot(self, mock_reboot): + info = seamicro._parse_driver_info(self.node) + + mock_reboot.return_value = states.POWER_ON + + with task_manager.acquire(self.context, [info['uuid']], + shared=False) as task: + task.resources[0].driver.power.reboot(task, self.node) + + mock_reboot.assert_called_once_with(self.node) + + def test_set_power_state_bad_state(self): + info = seamicro ._parse_driver_info(self.node) + self.get_server_mock = self.get_server_patcher.start() + self.get_server_mock.return_value = self.Server() + + with task_manager.acquire(self.context, [info['uuid']], + shared=False) as task: + self.assertRaises(exception.IronicException, + task.resources[0].driver.power.set_power_state, + task, self.node, "BAD_PSTATE") + self.get_server_patcher.stop() + + @mock.patch.object(seamicro, '_power_on') + def test_set_power_state_on_good(self, mock_power_on): + info = seamicro._parse_driver_info(self.node) + + mock_power_on.return_value = states.POWER_ON + + with task_manager.acquire(self.context, [info['uuid']], + shared=False) as task: + task.resources[0].driver.power.set_power_state(task, + self.node, + states.POWER_ON) + + mock_power_on.assert_called_once_with(self.node) + + @mock.patch.object(seamicro, '_power_on') + def test_set_power_state_on_fail(self, mock_power_on): + info = seamicro._parse_driver_info(self.node) + + mock_power_on.return_value = states.POWER_OFF + + with task_manager.acquire(self.context, [info['uuid']], + shared=False) as task: + self.assertRaises(exception.PowerStateFailure, + task.resources[0] + .driver.power.set_power_state, + task, self.node, states.POWER_ON) + + mock_power_on.assert_called_once_with(self.node) + + @mock.patch.object(seamicro, '_power_off') + def test_set_power_state_off_good(self, mock_power_off): + info = seamicro._parse_driver_info(self.node) + + mock_power_off.return_value = states.POWER_OFF + + with task_manager.acquire(self.context, [info['uuid']], + shared=False) as task: + task.resources[0].driver.power.\ + set_power_state(task, self.node, states.POWER_OFF) + + mock_power_off.assert_called_once_with(self.node) + + @mock.patch.object(seamicro, '_power_off') + def test_set_power_state_off_fail(self, mock_power_off): + info = seamicro._parse_driver_info(self.node) + + mock_power_off.return_value = states.POWER_ON + + with task_manager.acquire(self.context, [info['uuid']], + shared=False) as task: + self.assertRaises(exception.PowerStateFailure, + task.resources[0] + .driver.power.set_power_state, + task, self.node, states.POWER_OFF) + + mock_power_off.assert_called_once_with(self.node) diff --git a/requirements.txt b/requirements.txt index e0eca581b3..1eae15e583 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,3 +28,4 @@ jsonpatch>=1.1 WSME>=0.6 Jinja2 pyghmi>=0.5.8 +python-seamicroclient>=0.1.0,<2.0 diff --git a/setup.cfg b/setup.cfg index 434d1e3c3d..80f821cbff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,9 +35,11 @@ ironic.drivers = fake_ipminative = ironic.drivers.fake:FakeIPMINativeDriver fake_ssh = ironic.drivers.fake:FakeSSHDriver fake_pxe = ironic.drivers.fake:FakePXEDriver + fake_seamicro = ironic.drivers.fake:FakeSeaMicroDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver + pxe_seamicro = ironic.drivers.pxe:PXEAndSeaMicroDriver [pbr] autodoc_index_modules = True