diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 6b62c380a8..09e3fb6a24 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -331,6 +331,10 @@ class AMTFailure(IronicException): message = _("AMT call failed: %(cmd)s.") +class MSFTOCSClientApiException(IronicException): + message = _("MSFT OCS call failed.") + + class SSHConnectFailed(IronicException): message = _("Failed to establish SSH connection to host %(host)s.") diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index 0e1dde8dad..e0383ef03c 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -37,6 +37,8 @@ from ironic.drivers.modules import ipminative from ironic.drivers.modules import ipmitool from ironic.drivers.modules.irmc import management as irmc_management from ironic.drivers.modules.irmc import power as irmc_power +from ironic.drivers.modules.msftocs import management as msftocs_management +from ironic.drivers.modules.msftocs import power as msftocs_power from ironic.drivers.modules import pxe from ironic.drivers.modules import seamicro from ironic.drivers.modules import snmp @@ -234,3 +236,12 @@ class FakeAMTDriver(base.BaseDriver): self.power = amt_power.AMTPower() self.deploy = fake.FakeDeploy() self.management = amt_mgmt.AMTManagement() + + +class FakeMSFTOCSDriver(base.BaseDriver): + """Fake MSFT OCS driver.""" + + def __init__(self): + self.power = msftocs_power.MSFTOCSPower() + self.deploy = fake.FakeDeploy() + self.management = msftocs_management.MSFTOCSManagement() diff --git a/ironic/drivers/modules/msftocs/__init__.py b/ironic/drivers/modules/msftocs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/drivers/modules/msftocs/common.py b/ironic/drivers/modules/msftocs/common.py new file mode 100644 index 0000000000..97d069967b --- /dev/null +++ b/ironic/drivers/modules/msftocs/common.py @@ -0,0 +1,110 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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. + +import copy +import re + +import six + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.drivers.modules.msftocs import msftocsclient + +REQUIRED_PROPERTIES = { + 'msftocs_base_url': _('Base url of the OCS chassis manager REST API, ' + 'e.g.: http://10.0.0.1:8000. Required.'), + 'msftocs_blade_id': _('Blade id, must be a number between 1 and the ' + 'maximum number of blades available in the chassis. ' + 'Required.'), + 'msftocs_username': _('Username to access the chassis manager REST API. ' + 'Required.'), + 'msftocs_password': _('Password to access the chassis manager REST API. ' + 'Required.'), +} + + +def get_client_info(driver_info): + """Returns an instance of the REST API client and the blade id. + + :param driver_info: the node's driver_info dict. + """ + client = msftocsclient.MSFTOCSClientApi(driver_info['msftocs_base_url'], + driver_info['msftocs_username'], + driver_info['msftocs_password']) + return client, driver_info['msftocs_blade_id'] + + +def get_properties(): + """Returns the driver's properties.""" + return copy.deepcopy(REQUIRED_PROPERTIES) + + +def _is_valid_url(url): + """Checks whether a URL is valid. + + :param url: a url string. + :returns: True if the url is valid or None, False otherwise. + """ + r = re.compile( + r'^https?://' + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)*[A-Z]{2,6}\.?|' + r'localhost|' + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' + r'(?::\d+)?' + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + + return bool(isinstance(url, six.string_types) and r.search(url)) + + +def _check_required_properties(driver_info): + """Checks if all required properties are present. + + :param driver_info: the node's driver_info dict. + :raises: MissingParameterValue if one or more required properties are + missing. + """ + missing_properties = set(REQUIRED_PROPERTIES) - set(driver_info) + if missing_properties: + raise exception.MissingParameterValue( + _('The following parameters were missing: %s') % + ' '.join(missing_properties)) + + +def parse_driver_info(node): + """Checks for the required properties and values validity. + + :param node: the target node. + :raises: MissingParameterValue if one or more required properties are + missing. + :raises: InvalidParameterValue if a parameter value is invalid. + """ + driver_info = node.driver_info + _check_required_properties(driver_info) + + base_url = driver_info.get('msftocs_base_url') + if not _is_valid_url(base_url): + raise exception.InvalidParameterValue( + _('"%s" is not a valid "msftocs_base_url"') % base_url) + + blade_id = driver_info.get('msftocs_blade_id') + try: + blade_id = int(blade_id) + except ValueError: + raise exception.InvalidParameterValue( + _('"%s" is not a valid "msftocs_blade_id"') % blade_id) + if blade_id < 1: + raise exception.InvalidParameterValue( + _('"msftocs_blade_id" must be greater than 0. The provided value ' + 'is: %s') % blade_id) diff --git a/ironic/drivers/modules/msftocs/management.py b/ironic/drivers/modules/msftocs/management.py new file mode 100644 index 0000000000..d9a6600d96 --- /dev/null +++ b/ironic/drivers/modules/msftocs/management.py @@ -0,0 +1,118 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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. + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules.msftocs import common as msftocs_common +from ironic.drivers.modules.msftocs import msftocsclient +from ironic.drivers import utils as drivers_utils + +BOOT_TYPE_TO_DEVICE_MAP = { + msftocsclient.BOOT_TYPE_FORCE_PXE: boot_devices.PXE, + msftocsclient.BOOT_TYPE_FORCE_DEFAULT_HDD: boot_devices.DISK, + msftocsclient.BOOT_TYPE_FORCE_INTO_BIOS_SETUP: boot_devices.BIOS, +} +DEVICE_TO_BOOT_TYPE_MAP = {v: k for k, v in BOOT_TYPE_TO_DEVICE_MAP.items()} + +DEFAULT_BOOT_DEVICE = boot_devices.DISK + + +class MSFTOCSManagement(base.ManagementInterface): + def get_properties(self): + """Returns the driver's properties.""" + return msftocs_common.get_properties() + + def validate(self, task): + """Validate the driver_info in the node. + + Check if the driver_info contains correct required fields. + + :param task: a TaskManager instance containing the target node. + :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if any parameters have invalid values. + """ + msftocs_common.parse_driver_info(task.node) + + def get_supported_boot_devices(self): + """Get a list of the supported boot devices. + + :returns: A list with the supported boot devices. + """ + return list(BOOT_TYPE_TO_DEVICE_MAP.values()) + + def _check_valid_device(self, device, node): + """Checks if the desired boot device is valid for this driver. + + :param device: a boot device. + :param node: the target node. + :raises: InvalidParameterValue if the boot device is not valid. + """ + if device not in DEVICE_TO_BOOT_TYPE_MAP: + raise exception.InvalidParameterValue( + _("set_boot_device called with invalid device %(device)s for " + "node %(node_id)s.") % + {'device': device, 'node_id': node.uuid}) + + @task_manager.require_exclusive_lock + def set_boot_device(self, task, device, persistent=False): + """Set the boot device for the task's node. + + Set the boot device to use on next boot of the node. + + :param task: a task from TaskManager. + :param device: the boot device. + :param persistent: Boolean value. True if the boot device will + persist to all future boots, False if not. + Default: False. + :raises: InvalidParameterValue if an invalid boot device is specified. + """ + self._check_valid_device(device, task.node) + client, blade_id = msftocs_common.get_client_info( + task.node.driver_info) + + boot_mode = drivers_utils.get_node_capability(task.node, 'boot_mode') + uefi = (boot_mode == 'uefi') + + boot_type = DEVICE_TO_BOOT_TYPE_MAP[device] + client.set_next_boot(blade_id, boot_type, persistent, uefi) + + def get_boot_device(self, task): + """Get the current boot device for the task's node. + + Returns the current boot device of the node. + + :param task: a task from TaskManager. + :returns: a dictionary containing: + :boot_device: the boot device + :persistent: Whether the boot device will persist to all + future boots or not, None if it is unknown. + """ + client, blade_id = msftocs_common.get_client_info( + task.node.driver_info) + device = BOOT_TYPE_TO_DEVICE_MAP.get( + client.get_next_boot(blade_id), DEFAULT_BOOT_DEVICE) + + # Note(alexpilotti): Although the ChasssisManager REST API allows to + # specify the persistent boot status in SetNextBoot, currently it does + # not provide a way to retrieve the value with GetNextBoot. + # This is being addressed in the ChassisManager API. + return {'boot_device': device, + 'persistent': None} + + def get_sensors_data(self, task): + raise NotImplementedError() diff --git a/ironic/drivers/modules/msftocs/msftocsclient.py b/ironic/drivers/modules/msftocs/msftocsclient.py new file mode 100644 index 0000000000..92df607a2f --- /dev/null +++ b/ironic/drivers/modules/msftocs/msftocsclient.py @@ -0,0 +1,177 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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. + +""" +MSFT OCS ChassisManager v2.0 REST API client +https://github.com/MSOpenTech/ChassisManager +""" + +import posixpath +from xml.etree import ElementTree + +from oslo_log import log +import requests +from requests import auth +from requests import exceptions as requests_exceptions + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE + +LOG = log.getLogger(__name__) + +WCSNS = 'http://schemas.datacontract.org/2004/07/Microsoft.GFS.WCS.Contracts' +COMPLETION_CODE_SUCCESS = "Success" + +BOOT_TYPE_UNKNOWN = 0 +BOOT_TYPE_NO_OVERRIDE = 1 +BOOT_TYPE_FORCE_PXE = 2 +BOOT_TYPE_FORCE_DEFAULT_HDD = 3 +BOOT_TYPE_FORCE_INTO_BIOS_SETUP = 4 +BOOT_TYPE_FORCE_FLOPPY_OR_REMOVABLE = 5 + +BOOT_TYPE_MAP = { + 'Unknown': BOOT_TYPE_UNKNOWN, + 'NoOverride': BOOT_TYPE_NO_OVERRIDE, + 'ForcePxe': BOOT_TYPE_FORCE_PXE, + 'ForceDefaultHdd': BOOT_TYPE_FORCE_DEFAULT_HDD, + 'ForceIntoBiosSetup': BOOT_TYPE_FORCE_INTO_BIOS_SETUP, + 'ForceFloppyOrRemovable': BOOT_TYPE_FORCE_FLOPPY_OR_REMOVABLE, +} + +POWER_STATUS_ON = "ON" +POWER_STATUS_OFF = "OFF" + + +class MSFTOCSClientApi(object): + def __init__(self, base_url, username, password): + self._base_url = base_url + self._username = username + self._password = password + + def _exec_cmd(self, rel_url): + """Executes a command by calling the chassis manager API.""" + url = posixpath.join(self._base_url, rel_url) + try: + response = requests.get( + url, auth=auth.HTTPBasicAuth(self._username, self._password)) + response.raise_for_status() + except requests_exceptions.RequestException as ex: + LOG.exception(_LE("HTTP call failed: %s"), ex) + raise exception.MSFTOCSClientApiException( + _("HTTP call failed: %s") % ex.message) + + xml_response = response.text + LOG.debug("Call to %(url)s got response: %(xml_response)s", + {"url": url, "xml_response": xml_response}) + return xml_response + + def _check_completion_code(self, xml_response): + try: + et = ElementTree.fromstring(xml_response) + except ElementTree.ParseError as ex: + LOG.exception(_LE("XML parsing failed: %s"), ex) + raise exception.MSFTOCSClientApiException( + _("Invalid XML: %s") % xml_response) + item = et.find("./n:completionCode", namespaces={'n': WCSNS}) + if item is None or item.text != COMPLETION_CODE_SUCCESS: + raise exception.MSFTOCSClientApiException( + _("Operation failed: %s") % xml_response) + return et + + def get_blade_state(self, blade_id): + """Returns whether a blade's chipset is receiving power (soft-power). + + :param blade_id: the blade id + :returns: one of: + POWER_STATUS_ON, + POWER_STATUS_OFF + :raises: MSFTOCSClientApiException + """ + et = self._check_completion_code( + self._exec_cmd("GetBladeState?bladeId=%d" % blade_id)) + return et.find('./n:bladeState', namespaces={'n': WCSNS}).text + + def set_blade_on(self, blade_id): + """Supplies power to a blade chipset (soft-power state). + + :param blade_id: the blade id + :raises: MSFTOCSClientApiException + """ + self._check_completion_code( + self._exec_cmd("SetBladeOn?bladeId=%d" % blade_id)) + + def set_blade_off(self, blade_id): + """Shuts down a given blade (soft-power state). + + :param blade_id: the blade id + :raises: MSFTOCSClientApiException + """ + self._check_completion_code( + self._exec_cmd("SetBladeOff?bladeId=%d" % blade_id)) + + def set_blade_power_cycle(self, blade_id, off_time=0): + """Performs a soft reboot of a given blade. + + :param blade_id: the blade id + :param off_time: seconds to wait between shutdown and boot + :raises: MSFTOCSClientApiException + """ + self._check_completion_code( + self._exec_cmd("SetBladeActivePowerCycle?bladeId=%(blade_id)d&" + "offTime=%(off_time)d" % + {"blade_id": blade_id, "off_time": off_time})) + + def get_next_boot(self, blade_id): + """Returns the next boot device configured for a given blade. + + :param blade_id: the blade id + :returns: one of: + BOOT_TYPE_UNKNOWN, + BOOT_TYPE_NO_OVERRIDE, + BOOT_TYPE_FORCE_PXE, BOOT_TYPE_FORCE_DEFAULT_HDD, + BOOT_TYPE_FORCE_INTO_BIOS_SETUP, + BOOT_TYPE_FORCE_FLOPPY_OR_REMOVABLE + :raises: MSFTOCSClientApiException + """ + et = self._check_completion_code( + self._exec_cmd("GetNextBoot?bladeId=%d" % blade_id)) + return BOOT_TYPE_MAP[ + et.find('./n:nextBoot', namespaces={'n': WCSNS}).text] + + def set_next_boot(self, blade_id, boot_type, persistent=True, uefi=True): + """Sets the next boot device for a given blade. + + :param blade_id: the blade id + :param boot_type: possible values: + BOOT_TYPE_UNKNOWN, + BOOT_TYPE_NO_OVERRIDE, + BOOT_TYPE_FORCE_PXE, + BOOT_TYPE_FORCE_DEFAULT_HDD, + BOOT_TYPE_FORCE_INTO_BIOS_SETUP, + BOOT_TYPE_FORCE_FLOPPY_OR_REMOVABLE + :param persistent: whether this setting affects the next boot only or + every subsequent boot + :param uefi: True if UEFI, False otherwise + :raises: MSFTOCSClientApiException + """ + self._check_completion_code( + self._exec_cmd( + "SetNextBoot?bladeId=%(blade_id)d&bootType=%(boot_type)d&" + "uefi=%(uefi)s&persistent=%(persistent)s" % + {"blade_id": blade_id, + "boot_type": boot_type, + "uefi": str(uefi).lower(), + "persistent": str(persistent).lower()})) diff --git a/ironic/drivers/modules/msftocs/power.py b/ironic/drivers/modules/msftocs/power.py new file mode 100644 index 0000000000..e0c8824644 --- /dev/null +++ b/ironic/drivers/modules/msftocs/power.py @@ -0,0 +1,106 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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. + +""" +MSFT OCS Power Driver +""" +from oslo_log import log + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LE +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base +from ironic.drivers.modules.msftocs import common as msftocs_common +from ironic.drivers.modules.msftocs import msftocsclient + +LOG = log.getLogger(__name__) + +POWER_STATES_MAP = { + msftocsclient.POWER_STATUS_ON: states.POWER_ON, + msftocsclient.POWER_STATUS_OFF: states.POWER_OFF, +} + + +class MSFTOCSPower(base.PowerInterface): + def get_properties(self): + """Returns the driver's properties.""" + return msftocs_common.get_properties() + + def validate(self, task): + """Validate the driver_info in the node. + + Check if the driver_info contains correct required fields. + + :param task: a TaskManager instance containing the target node. + :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if any parameters have invalid values. + """ + msftocs_common.parse_driver_info(task.node) + + def get_power_state(self, task): + """Get the power state from the node. + + :param task: a TaskManager instance containing the target node. + :raises: MSFTOCSClientApiException. + """ + client, blade_id = msftocs_common.get_client_info( + task.node.driver_info) + return POWER_STATES_MAP[client.get_blade_state(blade_id)] + + @task_manager.require_exclusive_lock + def set_power_state(self, task, pstate): + """Set the power state of the node. + + Turn the node power on or off. + + :param task: a TaskManager instance contains the target node. + :param pstate : The desired power state of the node. + :raises: PowerStateFailure if the power cannot set to pstate. + :raises: InvalidParameterValue + """ + client, blade_id = msftocs_common.get_client_info( + task.node.driver_info) + + try: + if pstate == states.POWER_ON: + client.set_blade_on(blade_id) + elif pstate == states.POWER_OFF: + client.set_blade_off(blade_id) + else: + raise exception.InvalidParameterValue( + _('Unsupported target_state: %s') % pstate) + except exception.MSFTOCSClientApiException as ex: + LOG.exception(_LE("Changing the power state to %(pstate)s failed. " + "Error: %(err_msg)s"), + {"pstate": pstate, "err_msg": ex}) + raise exception.PowerStateFailure(pstate=pstate) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """Cycle the power of the node + + :param task: a TaskManager instance contains the target node. + :raises: PowerStateFailure if failed to reboot. + """ + client, blade_id = msftocs_common.get_client_info( + task.node.driver_info) + try: + client.set_blade_power_cycle(blade_id) + except exception.MSFTOCSClientApiException as ex: + LOG.exception(_LE("Reboot failed. Error: %(err_msg)s"), + {"err_msg": ex}) + raise exception.PowerStateFailure(pstate=states.REBOOT) diff --git a/ironic/drivers/pxe.py b/ironic/drivers/pxe.py index 33f6e764b6..abd7951a2a 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -35,6 +35,8 @@ from ironic.drivers.modules import ipminative from ironic.drivers.modules import ipmitool from ironic.drivers.modules.irmc import management as irmc_management from ironic.drivers.modules.irmc import power as irmc_power +from ironic.drivers.modules.msftocs import management as msftocs_management +from ironic.drivers.modules.msftocs import power as msftocs_power from ironic.drivers.modules import pxe from ironic.drivers.modules import seamicro from ironic.drivers.modules import snmp @@ -266,3 +268,20 @@ class PXEAndAMTDriver(base.BaseDriver): self.deploy = pxe.PXEDeploy() self.management = amt_management.AMTManagement() self.vendor = amt_vendor.AMTPXEVendorPassthru() + + +class PXEAndMSFTOCSDriver(base.BaseDriver): + """PXE + MSFT OCS driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.msftocs.power.MSFTOCSPower` for power on/off + and reboot with :class:`ironic.driver.pxe.PXE` for image deployment. + Implementations are in those respective classes; this class is merely the + glue between them. + """ + + def __init__(self): + self.power = msftocs_power.MSFTOCSPower() + self.deploy = pxe.PXEDeploy() + self.management = msftocs_management.MSFTOCSManagement() + self.vendor = pxe.VendorPassthru() diff --git a/ironic/tests/db/utils.py b/ironic/tests/db/utils.py index dbb8e8d954..0f1a58f1c9 100644 --- a/ironic/tests/db/utils.py +++ b/ironic/tests/db/utils.py @@ -129,6 +129,15 @@ def get_test_amt_info(): } +def get_test_msftocs_info(): + return { + "msftocs_base_url": "http://fakehost:8000", + "msftocs_username": "admin", + "msftocs_password": "fake", + "msftocs_blade_id": 1, + } + + def get_test_agent_instance_info(): return { 'image_source': 'fake-image', diff --git a/ironic/tests/drivers/msftocs/__init__.py b/ironic/tests/drivers/msftocs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/drivers/msftocs/test_common.py b/ironic/tests/drivers/msftocs/test_common.py new file mode 100644 index 0000000000..75fe71fe28 --- /dev/null +++ b/ironic/tests/drivers/msftocs/test_common.py @@ -0,0 +1,110 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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 MSFT OCS common functions +""" + +import mock + +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.msftocs import common as msftocs_common +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 +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = db_utils.get_test_msftocs_info() + + +class MSFTOCSCommonTestCase(db_base.DbTestCase): + def setUp(self): + super(MSFTOCSCommonTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_msftocs') + self.info = INFO_DICT + self.node = obj_utils.create_test_node(self.context, + driver='fake_msftocs', + driver_info=self.info) + + def test_get_client_info(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + driver_info = task.node.driver_info + (client, blade_id) = msftocs_common.get_client_info(driver_info) + + self.assertEqual(driver_info['msftocs_base_url'], client._base_url) + self.assertEqual(driver_info['msftocs_username'], client._username) + self.assertEqual(driver_info['msftocs_password'], client._password) + self.assertEqual(driver_info['msftocs_blade_id'], blade_id) + + @mock.patch.object(msftocs_common, '_is_valid_url', autospec=True) + def test_parse_driver_info(self, mock_is_valid_url): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + msftocs_common.parse_driver_info(task.node) + mock_is_valid_url.assert_called_once_with( + task.node.driver_info['msftocs_base_url']) + + def test_parse_driver_info_fail_missing_param(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + del task.node.driver_info['msftocs_base_url'] + self.assertRaises(exception.MissingParameterValue, + msftocs_common.parse_driver_info, + task.node) + + def test_parse_driver_info_fail_bad_url(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.node.driver_info['msftocs_base_url'] = "bad-url" + self.assertRaises(exception.InvalidParameterValue, + msftocs_common.parse_driver_info, + task.node) + + def test_parse_driver_info_fail_bad_blade_id_type(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.node.driver_info['msftocs_blade_id'] = "bad-blade-id" + self.assertRaises(exception.InvalidParameterValue, + msftocs_common.parse_driver_info, + task.node) + + def test_parse_driver_info_fail_bad_blade_id_value(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.node.driver_info['msftocs_blade_id'] = 0 + self.assertRaises(exception.InvalidParameterValue, + msftocs_common.parse_driver_info, + task.node) + + def test__is_valid_url(self): + self.assertIs(True, msftocs_common._is_valid_url("http://fake.com")) + self.assertIs( + True, msftocs_common._is_valid_url("http://www.fake.com")) + self.assertIs(True, msftocs_common._is_valid_url("http://FAKE.com")) + self.assertIs(True, msftocs_common._is_valid_url("http://fake")) + self.assertIs( + True, msftocs_common._is_valid_url("http://fake.com/blah")) + self.assertIs(True, msftocs_common._is_valid_url("http://localhost")) + self.assertIs(True, msftocs_common._is_valid_url("https://fake.com")) + self.assertIs(True, msftocs_common._is_valid_url("http://10.0.0.1")) + self.assertIs(False, msftocs_common._is_valid_url("bad-url")) + self.assertIs(False, msftocs_common._is_valid_url("http://.bad-url")) + self.assertIs(False, msftocs_common._is_valid_url("http://bad-url$")) + self.assertIs(False, msftocs_common._is_valid_url("http://$bad-url")) + self.assertIs(False, msftocs_common._is_valid_url("http://bad$url")) + self.assertIs(False, msftocs_common._is_valid_url(None)) + self.assertIs(False, msftocs_common._is_valid_url(0)) diff --git a/ironic/tests/drivers/msftocs/test_management.py b/ironic/tests/drivers/msftocs/test_management.py new file mode 100644 index 0000000000..098d25573f --- /dev/null +++ b/ironic/tests/drivers/msftocs/test_management.py @@ -0,0 +1,131 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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 MSFT OCS ManagementInterface +""" + +import mock + +from ironic.common import boot_devices +from ironic.common import exception +from ironic.conductor import task_manager +from ironic.drivers.modules.msftocs import common as msftocs_common +from ironic.drivers.modules.msftocs import msftocsclient +from ironic.drivers import utils as drivers_utils +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 +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = db_utils.get_test_msftocs_info() + + +class MSFTOCSManagementTestCase(db_base.DbTestCase): + def setUp(self): + super(MSFTOCSManagementTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_msftocs') + self.info = INFO_DICT + self.node = obj_utils.create_test_node(self.context, + driver='fake_msftocs', + driver_info=self.info) + + def test_get_properties(self): + expected = msftocs_common.REQUIRED_PROPERTIES + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(expected, task.driver.get_properties()) + + @mock.patch.object(msftocs_common, 'parse_driver_info', autospec=True) + def test_validate(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.power.validate(task) + mock_drvinfo.assert_called_once_with(task.node) + + @mock.patch.object(msftocs_common, 'parse_driver_info', autospec=True) + def test_validate_fail(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_drvinfo.side_effect = exception.InvalidParameterValue('x') + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.validate, + task) + + def test_get_supported_boot_devices(self): + expected = [boot_devices.PXE, boot_devices.DISK, boot_devices.BIOS] + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual( + sorted(expected), + sorted(task.driver.management.get_supported_boot_devices())) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def _test_set_boot_device_one_time(self, persistent, uefi, + mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + if uefi: + drivers_utils.add_node_capability(task, 'boot_mode', 'uefi') + + task.driver.management.set_boot_device( + task, boot_devices.PXE, persistent) + + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.set_next_boot.assert_called_once_with( + blade_id, msftocsclient.BOOT_TYPE_FORCE_PXE, persistent, uefi) + + def test_set_boot_device_one_time(self): + self._test_set_boot_device_one_time(False, False) + + def test_set_boot_device_persistent(self): + self._test_set_boot_device_one_time(True, False) + + def test_set_boot_device_uefi(self): + self._test_set_boot_device_one_time(True, True) + + def test_set_boot_device_fail(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.management.set_boot_device, + task, 'fake-device') + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_get_boot_device(self, mock_gci): + expected = {'boot_device': boot_devices.DISK, 'persistent': None} + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + force_hdd = msftocsclient.BOOT_TYPE_FORCE_DEFAULT_HDD + mock_c.get_next_boot.return_value = force_hdd + + self.assertEqual(expected, + task.driver.management.get_boot_device(task)) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.get_next_boot.assert_called_once_with(blade_id) + + def test_get_sensor_data(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertRaises(NotImplementedError, + task.driver.management.get_sensors_data, + task) diff --git a/ironic/tests/drivers/msftocs/test_msftocsclient.py b/ironic/tests/drivers/msftocs/test_msftocsclient.py new file mode 100644 index 0000000000..66097d05e1 --- /dev/null +++ b/ironic/tests/drivers/msftocs/test_msftocsclient.py @@ -0,0 +1,182 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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 MSFT OCS REST API client +""" + +import mock +import requests +from requests import exceptions as requests_exceptions + +from ironic.common import exception +from ironic.drivers.modules.msftocs import msftocsclient +from ironic.tests import base + + +FAKE_BOOT_RESPONSE = ( + '' + 'Success' + '1' + 'Success' + '1' + 'ForcePxe' + '') % msftocsclient.WCSNS + +FAKE_BLADE_RESPONSE = ( + '' + 'Success' + '1' + '' + '1' + '') % msftocsclient.WCSNS + +FAKE_POWER_STATE_RESPONSE = ( + '' + 'Success' + '1' + 'Blade Power is On, firmware decompressed' + '' + '1' + '0' + 'ON' + '') % msftocsclient.WCSNS + +FAKE_BLADE_STATE_RESPONSE = ( + '' + 'Success' + '1' + '' + '1' + 'ON' + '') % msftocsclient.WCSNS + + +class MSFTOCSClientApiTestCase(base.TestCase): + def setUp(self): + super(MSFTOCSClientApiTestCase, self).setUp() + self._fake_base_url = "http://fakehost:8000" + self._fake_username = "admin" + self._fake_password = 'fake' + self._fake_blade_id = 1 + self._client = msftocsclient.MSFTOCSClientApi( + self._fake_base_url, self._fake_username, self._fake_password) + + @mock.patch.object(requests, 'get', autospec=True) + def test__exec_cmd(self, mock_get): + fake_response_text = 'fake_response_text' + fake_rel_url = 'fake_rel_url' + mock_get.return_value.text = 'fake_response_text' + + self.assertEqual(fake_response_text, + self._client._exec_cmd(fake_rel_url)) + mock_get.assert_called_once_with( + self._fake_base_url + "/" + fake_rel_url, auth=mock.ANY) + + @mock.patch.object(requests, 'get', autospec=True) + def test__exec_cmd_http_get_fail(self, mock_get): + fake_rel_url = 'fake_rel_url' + mock_get.side_effect = requests_exceptions.ConnectionError('x') + + self.assertRaises(exception.MSFTOCSClientApiException, + self._client._exec_cmd, + fake_rel_url) + mock_get.assert_called_once_with( + self._fake_base_url + "/" + fake_rel_url, auth=mock.ANY) + + def test__check_completion_code(self): + et = self._client._check_completion_code(FAKE_BOOT_RESPONSE) + self.assertEqual('{%s}BootResponse' % msftocsclient.WCSNS, et.tag) + + def test__check_completion_code_fail(self): + self.assertRaises(exception.MSFTOCSClientApiException, + self._client._check_completion_code, + '' % msftocsclient.WCSNS) + + def test__check_completion_with_bad_completion_code_fail(self): + self.assertRaises(exception.MSFTOCSClientApiException, + self._client._check_completion_code, + '' + 'Fail' + '' % msftocsclient.WCSNS) + + def test__check_completion_code_xml_parsing_fail(self): + self.assertRaises(exception.MSFTOCSClientApiException, + self._client._check_completion_code, + 'bad_xml') + + @mock.patch.object( + msftocsclient.MSFTOCSClientApi, '_exec_cmd', autospec=True) + def test_get_blade_state(self, mock_exec_cmd): + mock_exec_cmd.return_value = FAKE_BLADE_STATE_RESPONSE + self.assertEqual( + msftocsclient.POWER_STATUS_ON, + self._client.get_blade_state(self._fake_blade_id)) + mock_exec_cmd.assert_called_once_with( + self._client, "GetBladeState?bladeId=%d" % self._fake_blade_id) + + @mock.patch.object( + msftocsclient.MSFTOCSClientApi, '_exec_cmd', autospec=True) + def test_set_blade_on(self, mock_exec_cmd): + mock_exec_cmd.return_value = FAKE_BLADE_RESPONSE + self._client.set_blade_on(self._fake_blade_id) + mock_exec_cmd.assert_called_once_with( + self._client, "SetBladeOn?bladeId=%d" % self._fake_blade_id) + + @mock.patch.object( + msftocsclient.MSFTOCSClientApi, '_exec_cmd', autospec=True) + def test_set_blade_off(self, mock_exec_cmd): + mock_exec_cmd.return_value = FAKE_BLADE_RESPONSE + self._client.set_blade_off(self._fake_blade_id) + mock_exec_cmd.assert_called_once_with( + self._client, "SetBladeOff?bladeId=%d" % self._fake_blade_id) + + @mock.patch.object( + msftocsclient.MSFTOCSClientApi, '_exec_cmd', autospec=True) + def test_set_blade_power_cycle(self, mock_exec_cmd): + mock_exec_cmd.return_value = FAKE_BLADE_RESPONSE + self._client.set_blade_power_cycle(self._fake_blade_id) + mock_exec_cmd.assert_called_once_with( + self._client, + "SetBladeActivePowerCycle?bladeId=%d&offTime=0" % + self._fake_blade_id) + + @mock.patch.object( + msftocsclient.MSFTOCSClientApi, '_exec_cmd', autospec=True) + def test_get_next_boot(self, mock_exec_cmd): + mock_exec_cmd.return_value = FAKE_BOOT_RESPONSE + self.assertEqual( + msftocsclient.BOOT_TYPE_FORCE_PXE, + self._client.get_next_boot(self._fake_blade_id)) + mock_exec_cmd.assert_called_once_with( + self._client, "GetNextBoot?bladeId=%d" % self._fake_blade_id) + + @mock.patch.object( + msftocsclient.MSFTOCSClientApi, '_exec_cmd', autospec=True) + def test_set_next_boot(self, mock_exec_cmd): + mock_exec_cmd.return_value = FAKE_BOOT_RESPONSE + self._client.set_next_boot(self._fake_blade_id, + msftocsclient.BOOT_TYPE_FORCE_PXE) + mock_exec_cmd.assert_called_once_with( + self._client, + "SetNextBoot?bladeId=%(blade_id)d&bootType=%(boot_type)d&" + "uefi=%(uefi)s&persistent=%(persistent)s" % + {"blade_id": self._fake_blade_id, + "boot_type": msftocsclient.BOOT_TYPE_FORCE_PXE, + "uefi": "true", "persistent": "true"}) diff --git a/ironic/tests/drivers/msftocs/test_power.py b/ironic/tests/drivers/msftocs/test_power.py new file mode 100644 index 0000000000..2c7f7788e0 --- /dev/null +++ b/ironic/tests/drivers/msftocs/test_power.py @@ -0,0 +1,163 @@ +# Copyright 2015 Cloudbase Solutions Srl +# All Rights Reserved. +# +# 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 MSFT OCS PowerInterface +""" + +import mock + +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules.msftocs import common as msftocs_common +from ironic.drivers.modules.msftocs import msftocsclient +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 +from ironic.tests.objects import utils as obj_utils + +INFO_DICT = db_utils.get_test_msftocs_info() + + +class MSFTOCSPowerTestCase(db_base.DbTestCase): + def setUp(self): + super(MSFTOCSPowerTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_msftocs') + self.info = INFO_DICT + self.node = obj_utils.create_test_node(self.context, + driver='fake_msftocs', + driver_info=self.info) + + def test_get_properties(self): + expected = msftocs_common.REQUIRED_PROPERTIES + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + self.assertEqual(expected, task.driver.get_properties()) + + @mock.patch.object(msftocs_common, 'parse_driver_info', autospec=True) + def test_validate(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + task.driver.power.validate(task) + mock_drvinfo.assert_called_once_with(task.node) + + @mock.patch.object(msftocs_common, 'parse_driver_info', autospec=True) + def test_validate_fail(self, mock_drvinfo): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_drvinfo.side_effect = exception.InvalidParameterValue('x') + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.validate, + task) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_get_power_state(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + mock_c.get_blade_state.return_value = msftocsclient.POWER_STATUS_ON + + self.assertEqual(states.POWER_ON, + task.driver.power.get_power_state(task)) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.get_blade_state.assert_called_once_with(blade_id) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_set_power_state_on(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + task.driver.power.set_power_state(task, states.POWER_ON) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.set_blade_on.assert_called_once_with(blade_id) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_set_power_state_off(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + task.driver.power.set_power_state(task, states.POWER_OFF) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.set_blade_off.assert_called_once_with(blade_id) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_set_power_state_blade_on_fail(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + ex = exception.MSFTOCSClientApiException('x') + mock_c.set_blade_on.side_effect = ex + + pstate = states.POWER_ON + self.assertRaises(exception.PowerStateFailure, + task.driver.power.set_power_state, + task, pstate) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.set_blade_on.assert_called_once_with(blade_id) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_set_power_state_invalid_parameter_fail(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + pstate = states.ERROR + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.set_power_state, + task, pstate) + mock_gci.assert_called_once_with(task.node.driver_info) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_reboot(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + task.driver.power.reboot(task) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.set_blade_power_cycle.assert_called_once_with(blade_id) + + @mock.patch.object(msftocs_common, 'get_client_info', autospec=True) + def test_reboot_fail(self, mock_gci): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + mock_c = mock.MagicMock(spec=msftocsclient.MSFTOCSClientApi) + blade_id = task.node.driver_info['msftocs_blade_id'] + mock_gci.return_value = (mock_c, blade_id) + + ex = exception.MSFTOCSClientApiException('x') + mock_c.set_blade_power_cycle.side_effect = ex + + self.assertRaises(exception.PowerStateFailure, + task.driver.power.reboot, + task) + mock_gci.assert_called_once_with(task.node.driver_info) + mock_c.set_blade_power_cycle.assert_called_once_with(blade_id) diff --git a/setup.cfg b/setup.cfg index 325cf1001c..9b5b7eec7e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ ironic.drivers = fake_irmc = ironic.drivers.fake:FakeIRMCDriver fake_vbox = ironic.drivers.fake:FakeVirtualBoxDriver fake_amt = ironic.drivers.fake:FakeAMTDriver + fake_msftocs = ironic.drivers.fake:FakeMSFTOCSDriver iscsi_ilo = ironic.drivers.ilo:IloVirtualMediaIscsiDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver @@ -65,6 +66,7 @@ ironic.drivers = pxe_snmp = ironic.drivers.pxe:PXEAndSNMPDriver pxe_irmc = ironic.drivers.pxe:PXEAndIRMCDriver pxe_amt = ironic.drivers.pxe:PXEAndAMTDriver + pxe_msftocs = ironic.drivers.pxe:PXEAndMSFTOCSDriver ironic.database.migration_backend = sqlalchemy = ironic.db.sqlalchemy.migration