# 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. Provides vendor passthru methods for SeaMicro specific functionality. """ import os import re from oslo_config import cfg from oslo_log import log as logging from oslo_service import loopingcall from oslo_utils import importutils from six.moves.urllib import parse as urlparse from ironic.common import boot_devices 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 states from ironic.conductor import task_manager from ironic.drivers import base from ironic.drivers.modules import console_utils seamicroclient = importutils.try_import('seamicroclient') if seamicroclient: from seamicroclient import client as seamicro_client from seamicroclient import exceptions as seamicro_client_exception 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__) _BOOT_DEVICES_MAP = { boot_devices.DISK: 'hd0', boot_devices.PXE: 'pxe', } REQUIRED_PROPERTIES = { 'seamicro_api_endpoint': _("API endpoint. Required."), 'seamicro_password': _("password. Required."), 'seamicro_server_id': _("server ID. Required."), 'seamicro_username': _("username. Required."), } OPTIONAL_PROPERTIES = { 'seamicro_api_version': _("version of SeaMicro API client; default is 2. " "Optional.") } 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): """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']} try: return seamicro_client.Client(kwargs['api_version'], **cl_kwargs) except seamicro_client_exception.UnsupportedVersion as e: raise exception.InvalidParameterValue(_( "Invalid 'seamicro_api_version' parameter. Reason: %s.") % e) def _parse_driver_info(node): """Parses and creates seamicro driver info :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 {} missing_info = [key for key in REQUIRED_PROPERTIES if not info.get(key)] if missing_info: raise exception.MissingParameterValue(_( "SeaMicro driver requires the following parameters to be set in" " node's driver_info: %s.") % missing_info) api_endpoint = info.get('seamicro_api_endpoint') username = info.get('seamicro_username') 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, 'port': port} 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_volume(driver_info, volume_id): """Get volume from volume_id.""" s_client = _get_client(**driver_info) return s_client.volumes.get(volume_id) def _get_power_status(node): """Get current power state of this node :param node: Ironic node one of :class:`ironic.db.models.Node` :raises: InvalidParameterValue if a seamicro parameter is invalid. :raises: MissingParameterValue if required seamicro parameters are missing. :raises: ServiceUnavailable on an error from SeaMicro Client. :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=node.uuid) except seamicro_client_exception.ClientException as ex: LOG.error(_LE("SeaMicro client exception %(msg)s for node %(uuid)s"), {'msg': ex.message, 'uuid': node.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. :raises: InvalidParameterValue if a seamicro parameter is invalid. :raises: MissingParameterValue if required seamicro parameters are missing. :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(_LW("Power-on failed for node %s."), node.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 :raises: InvalidParameterValue if a seamicro parameter is invalid. :raises: MissingParameterValue if required seamicro parameters are missing. :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(_LW("Power-off failed for node %s."), node.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 :raises: InvalidParameterValue if a seamicro parameter is invalid. :raises: MissingParameterValue if required seamicro parameters are missing. :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(_LW("Reboot failed for node %s."), node.uuid) timer = loopingcall.FixedIntervalLoopingCall(_wait_for_reboot, state, retries) server.reset() timer.start(interval=timeout).wait() return state[0] def _validate_volume(driver_info, volume_id): """Validates if volume is in Storage pools designated for ironic.""" volume = _get_volume(driver_info, volume_id) # Check if the ironic /ironic-/ naming scheme # is present in volume id try: pool_id = volume.id.split('/')[1].lower() except IndexError: pool_id = "" if "ironic-" in pool_id: return True else: raise exception.InvalidParameterValue(_( "Invalid volume id specified")) def _get_pools(driver_info, filters=None): """Get SeaMicro storage pools matching given filters.""" s_client = _get_client(**driver_info) return s_client.pools.list(filters=filters) def _create_volume(driver_info, volume_size): """Create volume in the SeaMicro storage pools designated for ironic.""" ironic_pools = _get_pools(driver_info, filters={'id': 'ironic-'}) if ironic_pools is None: raise exception.VendorPassthruException(_( "No storage pools found for ironic")) least_used_pool = sorted(ironic_pools, key=lambda x: x.freeSize)[0] return _get_client(**driver_info).volumes.create(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. This PowerInterface class provides a mechanism for controlling the power state of servers in a seamicro chassis. """ def get_properties(self): return COMMON_PROPERTIES def validate(self, task): """Check that node 'driver_info' is valid. Check that node 'driver_info' contains the required fields. :param task: a TaskManager instance containing the node to act on. :raises: MissingParameterValue if required seamicro parameters are missing. """ _parse_driver_info(task.node) def get_power_state(self, task): """Get the current power state of the task's node. Poll the host for the current power state of the node. :param task: a TaskManager instance containing the node to act on. :raises: ServiceUnavailable on an error from SeaMicro Client. :raises: InvalidParameterValue if a seamicro parameter is invalid. :raises: MissingParameterValue when a required parameter is missing :returns: power state. One of :class:`ironic.common.states`. """ return _get_power_status(task.node) @task_manager.require_exclusive_lock def set_power_state(self, task, pstate): """Turn the power on or off. Set the power state of a node. :param task: a TaskManager instance containing the node to act on. :param pstate: Either POWER_ON or POWER_OFF from :class: `ironic.common.states`. :raises: InvalidParameterValue if an invalid power state was specified or a seamicro parameter is invalid. :raises: MissingParameterValue when a required parameter is missing :raises: PowerStateFailure if the desired power state couldn't be set. """ if pstate == states.POWER_ON: state = _power_on(task.node) elif pstate == states.POWER_OFF: state = _power_off(task.node) else: raise exception.InvalidParameterValue(_( "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): """Cycles the power to the task's node. :param task: a TaskManager instance containing the node to act on. :raises: InvalidParameterValue if a seamicro parameter is invalid. :raises: MissingParameterValue if required seamicro parameters are missing. :raises: PowerStateFailure if the final state of the node is not POWER_ON. """ state = _reboot(task.node) if state != states.POWER_ON: raise exception.PowerStateFailure(pstate=states.POWER_ON) class VendorPassthru(base.VendorInterface): """SeaMicro vendor-specific methods.""" def get_properties(self): return COMMON_PROPERTIES def validate(self, task, method, **kwargs): _parse_driver_info(task.node) @base.passthru(['POST']) def set_node_vlan_id(self, task, **kwargs): """Sets an untagged vlan id for NIC 0 of node. @kwargs vlan_id: id of untagged vlan for NIC 0 of node """ node = task.node vlan_id = kwargs.get('vlan_id') if not vlan_id: raise exception.MissingParameterValue(_("No vlan id provided")) seamicro_info = _parse_driver_info(node) try: server = _get_server(seamicro_info) # remove current vlan for server if len(server.nic['0']['untaggedVlan']) > 0: server.unset_untagged_vlan(server.nic['0']['untaggedVlan']) server = server.refresh(5) server.set_untagged_vlan(vlan_id) except seamicro_client_exception.ClientException as ex: LOG.error(_LE("SeaMicro client exception: %s"), ex.message) raise exception.VendorPassthruException(message=ex.message) properties = node.properties properties['seamicro_vlan_id'] = vlan_id node.properties = properties node.save() @base.passthru(['POST']) def attach_volume(self, task, **kwargs): """Attach a volume to a node. Attach volume from SeaMicro storage pools for ironic to node. If kwargs['volume_id'] not given, Create volume in SeaMicro storage pool and attach to node. @kwargs volume_id: id of pre-provisioned volume that is to be attached as root volume of node @kwargs volume_size: size of new volume to be created and attached as root volume of node """ node = task.node seamicro_info = _parse_driver_info(node) volume_id = kwargs.get('volume_id') if volume_id is None: volume_size = kwargs.get('volume_size') if volume_size is None: raise exception.MissingParameterValue( _("No volume size provided for creating volume")) volume_id = _create_volume(seamicro_info, volume_size) if _validate_volume(seamicro_info, volume_id): try: server = _get_server(seamicro_info) server.detach_volume() server = server.refresh(5) server.attach_volume(volume_id) except seamicro_client_exception.ClientException as ex: LOG.error(_LE("SeaMicro client exception: %s"), ex.message) raise exception.VendorPassthruException(message=ex.message) properties = node.properties properties['seamicro_volume_id'] = volume_id node.properties = properties node.save() class Management(base.ManagementInterface): def get_properties(self): return COMMON_PROPERTIES def validate(self, task): """Check that 'driver_info' contains SeaMicro credentials. Validates whether the 'driver_info' property of the supplied task's node contains the required credentials information. :param task: a task from TaskManager. :raises: MissingParameterValue when a required parameter is missing """ _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 defined in :mod:`ironic.common.boot_devices`. """ return list(_BOOT_DEVICES_MAP.keys()) @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 reboot of the node. :param task: a task from TaskManager. :param device: the boot device, one of :mod:`ironic.common.boot_devices`. :param persistent: Boolean value. True if the boot device will persist to all future boots, False if not. Default: False. Ignored by this driver. :raises: InvalidParameterValue if an invalid boot device is specified or if a seamicro parameter is invalid. :raises: IronicException on an error from seamicro-client. :raises: MissingParameterValue when a required parameter is missing """ if device not in self.get_supported_boot_devices(): raise exception.InvalidParameterValue(_( "Invalid boot device %s specified.") % device) seamicro_info = _parse_driver_info(task.node) try: server = _get_server(seamicro_info) boot_device = _BOOT_DEVICES_MAP[device] server.set_boot_order(boot_device) except seamicro_client_exception.ClientException as ex: LOG.error(_LE("Seamicro set boot device failed for node " "%(node)s with the following error: %(error)s"), {'node': task.node.uuid, 'error': ex.args[0]}) raise exception.IronicException(message=ex.args[0]) def get_boot_device(self, task): """Get the current boot device for the task's node. Returns the current boot device of the node. Be aware that not all drivers support this. :param task: a task from TaskManager. :returns: a dictionary containing: :boot_device: the boot device, one of :mod:`ironic.common.boot_devices` or None if it is unknown. :persistent: Whether the boot device will persist to all future boots or not, None if it is unknown. """ # TODO(lucasagomes): The python-seamicroclient library currently # doesn't expose a method to get the boot device, update it once # it's implemented. return {'boot_device': None, 'persistent': None} def get_sensors_data(self, task): """Get sensors data method. Not implemented by this driver. :param task: a TaskManager instance. """ 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}