diff --git a/doc/source/deploy/drivers.rst b/doc/source/deploy/drivers.rst index 1ecf49b798..52a72195a3 100644 --- a/doc/source/deploy/drivers.rst +++ b/doc/source/deploy/drivers.rst @@ -11,4 +11,60 @@ DRAC with PXE deploy ^^^^^^^^^^^^^^^^^^^^ - Add ``pxe_drac`` to the list of ``enabled_drivers in`` ``/etc/ironic/ironic.conf`` -- Install openwsman-python package \ No newline at end of file +- Install openwsman-python package + +SNMP +---- + +The SNMP power driver enables control of power distribution units of the type +frequently found in data centre racks. PDUs frequently have a management +ethernet interface and SNMP support enabling control of the power outlets. + +The SNMP power driver works with the PXE driver for network deployment and +network-configured boot. + +Supported PDUs +^^^^^^^^^^^^^^ + +- American Power Conversion (APC) +- CyberPower (implemented according to MIB spec but not tested on hardware) +- EatonPower (implemented according to MIB spec but not tested on hardware) +- Teltronix + +Software Requirements +^^^^^^^^^^^^^^^^^^^^^ + +- The PySNMP package must be installed, variously referred to as ``pysnmp`` +or ``python-pysnmp`` + +Enabling the SNMP Power Driver +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Add ``pxe_snmp`` to the list of ``enabled_drivers`` in ``/etc/ironic/ironic.conf`` +- Ironic Conductor must be restarted for the new driver to be loaded. + +Ironic Node Configuration +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Nodes are configured for SNMP control by setting the Ironic node object's +``driver`` property to be ``pxe_snmp``. Further configuration values are +added to ``driver_info``: + +- ``snmp_address``: the IPv4 address of the PDU controlling this node. +- ``snmp_port``: (optional) A non-standard UDP port to use for SNMP operations. +If not specified, the default port (161) is used. +- ``snmp_outlet``: The power outlet on the PDU (1-based indexing). +- ``snmp_protocol``: (optional) SNMP protocol version +(permitted values ``1``, ``2c`` or ``3``). If not specified, SNMPv1 is chosen. +- ``snmp_community``: (Required for SNMPv1 and SNMPv2c) SNMP community +parameter for reads and writes to the PDU. +- ``snmp_security``: (Required for SNMPv3) SNMP security string. + +PDU Configuration +^^^^^^^^^^^^^^^^^ + +This version of the SNMP power driver does not support handling +PDU authentication credentials. When using SNMPv3, the PDU must be +configured for ``NoAuthentication`` and ``NoEncryption``. The +security name is used analagously to the SNMP community in early +SNMP versions. diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index a85d5e5ee3..94879cc0cf 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -1155,6 +1155,17 @@ #action_timeout=10 +[snmp] + +# +# Options defined in ironic.drivers.modules.snmp +# + +# Seconds to wait for power action to be completed (integer +# value) +#power_timeout=10 + + [ssh] # diff --git a/ironic/common/exception.py b/ironic/common/exception.py index edd0da32e4..fce6078793 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -459,3 +459,7 @@ class ImageCreationFailed(IronicException): class SwiftOperationError(IronicException): message = _("Swift operation '%(operation)s' failed: %(error)s") + + +class SNMPFailure(IronicException): + message = _("SNMP operation '%(operation)s' failed: %(error)s") diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index be43178584..246cc11780 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -32,6 +32,7 @@ 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 snmp from ironic.drivers.modules import ssh from ironic.drivers import utils @@ -145,3 +146,15 @@ class FakeDracDriver(base.BaseDriver): self.power = drac_power.DracPower() self.deploy = fake.FakeDeploy() self.management = drac_mgmt.DracManagement() + + +class FakeSNMPDriver(base.BaseDriver): + """Fake SNMP driver.""" + + def __init__(self): + if not importutils.try_import('pysnmp'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason="Unable to import pysnmp library") + self.power = snmp.SNMPPower() + self.deploy = fake.FakeDeploy() diff --git a/ironic/drivers/modules/snmp.py b/ironic/drivers/modules/snmp.py new file mode 100644 index 0000000000..a1d8bd8213 --- /dev/null +++ b/ironic/drivers/modules/snmp.py @@ -0,0 +1,657 @@ +# Copyright 2013,2014 Cray Inc +# +# Authors: David Hewson +# Stig Telfer +# Mark Goddard +# +# 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. + +""" +Ironic SNMP power manager. + +Provides basic power control using an SNMP-enabled smart power controller. +Uses a pluggable driver model to support devices with different SNMP object +models. + +""" + +import abc +import six + +from oslo.config import cfg +from oslo.utils import importutils + +from ironic.common import exception +from ironic.common import i18n +from ironic.common.i18n import _ +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 + +pysnmp = importutils.try_import('pysnmp') +if pysnmp: + from pysnmp.entity.rfc3413.oneliner import cmdgen + from pysnmp.proto import rfc1902 +else: + cmdgen = None + rfc1902 = None + +opts = [ + cfg.IntOpt('power_timeout', + default=10, + help='Seconds to wait for power action to be completed') + ] + +_LW = i18n._LW + +LOG = logging.getLogger(__name__) + +CONF = cfg.CONF +CONF.register_opts(opts, group='snmp') + + +SNMP_V1 = '1' +SNMP_V2C = '2c' +SNMP_V3 = '3' +SNMP_PORT = 161 + +REQUIRED_PROPERTIES = { + 'snmp_driver': _("PDU manufacturer driver. Required."), + 'snmp_address': _("PDU IPv4 address or hostname. Required."), + 'snmp_outlet': _("PDU power outlet index (1-based). Required."), +} +OPTIONAL_PROPERTIES = { + 'snmp_version': _("SNMP protocol version: %(v1)s, %(v2c)s, %(v3)s " + "(optional, default %(v1)s)") + % {"v1": SNMP_V1, "v2c": SNMP_V2C, "v3": SNMP_V3}, + 'snmp_port': _("SNMP port, default %(port)d") % {"port": SNMP_PORT}, + 'snmp_community': _("SNMP community. Required for versions %(v1)s, " + "%(v2c)s") + % {"v1": SNMP_V1, "v2c": SNMP_V2C}, + 'snmp_security': _("SNMP security name. Required for version %(v3)s") + % {"v3": SNMP_V3}, +} +COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() +COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES) + + +class SNMPClient(object): + """SNMP client object. + + Performs low level SNMP get and set operations. Encapsulates all + interaction with PySNMP to simplify dynamic importing and unit testing. + """ + + def __init__(self, address, port, version, community=None, security=None): + self.address = address + self.port = port + self.version = version + if self.version == SNMP_V3: + self.security = security + else: + self.community = community + self.cmd_gen = cmdgen.CommandGenerator() + + def _get_auth(self): + """Return the authorization data for an SNMP request. + + :returns: A + :class:`pysnmp.entity.rfc3413.oneliner.cmdgen.CommunityData` + object. + """ + if self.version == SNMP_V3: + # Handling auth/encryption credentials is not (yet) supported. + # This version supports a security name analagous to community. + return cmdgen.UsmUserData(self.security) + else: + mp_model = 1 if self.version == SNMP_V2C else 0 + return cmdgen.CommunityData(self.community, mpModel=mp_model) + + def _get_transport(self): + """Return the transport target for an SNMP request. + + :returns: A :class: + `pysnmp.entity.rfc3413.oneliner.cmdgen.UdpTransportTarget` object. + """ + # The transport target accepts timeout and retries parameters, which + # default to 1 (second) and 5 respectively. These are deemed sensible + # enough to allow for an unreliable network or slow device. + return cmdgen.UdpTransportTarget((self.address, self.port)) + + def get(self, oid): + """Use PySNMP to perform an SNMP GET operation on a single object. + + :param oid: The OID of the object to get. + :raises: SNMPFailure if an SNMP request fails. + :returns: The value of the requested object. + """ + results = self.cmd_gen.getCmd(self._get_auth(), self._get_transport(), + oid) + error_indication, error_status, error_index, var_binds = results + + if error_indication: + # SNMP engine-level error. + raise exception.SNMPFailure(operation="GET", + error=error_indication) + + if error_status: + # SNMP PDU error. + raise exception.SNMPFailure(operation="GET", + error=error_status.prettyPrint()) + + # We only expect a single value back + name, val = var_binds[0] + return val + + def set(self, oid, value): + """Use PySNMP to perform an SNMP SET operation on a single object. + + :param oid: The OID of the object to set. + :param value: The value of the object to set. + :raises: SNMPFailure if an SNMP request fails. + """ + results = self.cmd_gen.setCmd(self._get_auth(), self._get_transport(), + (oid, value)) + error_indication, error_status, error_index, var_binds = results + + if error_indication: + # SNMP engine-level error. + raise exception.SNMPFailure(operation="SET", + error=error_indication) + + if error_status: + # SNMP PDU error. + raise exception.SNMPFailure(operation="SET", + error=error_status.prettyPrint()) + + +def _get_client(snmp_info): + """Create and return an SNMP client object. + + :param snmp_info: SNMP driver info. + :returns: A :class:`SNMPClient` object. + """ + return SNMPClient(snmp_info["address"], + snmp_info["port"], + snmp_info["version"], + snmp_info.get("community"), + snmp_info.get("security")) + + +@six.add_metaclass(abc.ABCMeta) +class SNMPDriverBase(object): + """SNMP power driver base class. + + The SNMPDriver class hierarchy implements manufacturer-specific MIB actions + over SNMP to interface with different smart power controller products. + """ + + oid_enterprise = (1, 3, 6, 1, 4, 1) + retry_interval = 1 + + def __init__(self, snmp_info): + self.snmp_info = snmp_info + self.client = _get_client(snmp_info) + + @abc.abstractmethod + def _snmp_power_state(self): + """Perform the SNMP request required to retrieve the current power + state. + + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + + @abc.abstractmethod + def _snmp_power_on(self): + """Perform the SNMP request required to set the power on. + + :raises: SNMPFailure if an SNMP request fails. + """ + + @abc.abstractmethod + def _snmp_power_off(self): + """Perform the SNMP request required to set the power off. + + :raises: SNMPFailure if an SNMP request fails. + """ + + def _snmp_wait_for_state(self, goal_state): + """Wait for the power state of the PDU outlet to change. + + :param goal_state: The power state to wait for, one of + :class:`ironic.common.states`. + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + + def _poll_for_state(mutable): + """Called at an interval until the node's power is consistent. + + :param mutable: dict object containing "state" and "next_time" + :raises: SNMPFailure if an SNMP request fails. + """ + mutable["state"] = self._snmp_power_state() + if mutable["state"] == goal_state: + raise loopingcall.LoopingCallDone() + + mutable["next_time"] += self.retry_interval + if mutable["next_time"] >= CONF.snmp.power_timeout: + mutable["state"] = states.ERROR + raise loopingcall.LoopingCallDone() + + # Pass state to the looped function call in a mutable form. + state = {"state": None, "next_time": 0} + timer = loopingcall.FixedIntervalLoopingCall(_poll_for_state, + state) + timer.start(interval=self.retry_interval).wait() + LOG.debug("power state '%s'", state["state"]) + return state["state"] + + def power_state(self): + """Returns a node's current power state. + + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + return self._snmp_power_state() + + def power_on(self): + """Set the power state to this node to ON. + + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + self._snmp_power_on() + return self._snmp_wait_for_state(states.POWER_ON) + + def power_off(self): + """Set the power state to this node to OFF. + + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + self._snmp_power_off() + return self._snmp_wait_for_state(states.POWER_OFF) + + def power_reset(self): + """Reset the power to this node. + + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + power_result = self.power_off() + if power_result != states.POWER_OFF: + return states.ERROR + power_result = self.power_on() + if power_result != states.POWER_ON: + return states.ERROR + return power_result + + +class SNMPDriverSimple(SNMPDriverBase): + """SNMP driver base class for simple PDU devices. + + Here, simple refers to devices which provide a single SNMP object for + controlling the power state of an outlet. + + The default OID of the power state object is of the form + ... A different OID may be specified + by overriding the _snmp_oid method in a subclass. + """ + + def __init__(self, *args, **kwargs): + super(SNMPDriverSimple, self).__init__(*args, **kwargs) + self.oid = self._snmp_oid() + + @abc.abstractproperty + def oid_device(self): + """Device dependent portion of the power state object OID.""" + + @abc.abstractproperty + def value_power_on(self): + """Value representing power on state.""" + + @abc.abstractproperty + def value_power_off(self): + """Value representing power off state.""" + + def _snmp_oid(self): + """Return the OID of the power state object. + + :returns: Power state object OID as a tuple of integers. + """ + outlet = int(self.snmp_info['outlet']) + return self.oid_enterprise + self.oid_device + (outlet,) + + def _snmp_power_state(self): + state = self.client.get(self.oid) + + # Translate the state to an Ironic power state. + if state == self.value_power_on: + power_state = states.POWER_ON + elif state == self.value_power_off: + power_state = states.POWER_OFF + else: + LOG.warning(_LW("SNMP PDU %(addr)s outlet %(outlet)s: " + "unrecognised power state %(state)s."), + {'addr': self.snmp_info['address'], + 'outlet': self.snmp_info['outlet'], + 'state': state}) + power_state = states.ERROR + + return power_state + + def _snmp_power_on(self): + value = rfc1902.Integer(self.value_power_on) + self.client.set(self.oid, value) + + def _snmp_power_off(self): + value = rfc1902.Integer(self.value_power_off) + self.client.set(self.oid, value) + + +class SNMPDriverAPC(SNMPDriverSimple): + """SNMP driver class for APC PDU devices. + + SNMP objects for APC PDU: + 1.3.6.1.4.1.318.1.1.4.4.2.1.3 sPDUOutletCtl + Values: 1=On, 2=Off, 3=PowerCycle, [...more options follow] + """ + + oid_device = (318, 1, 1, 4, 4, 2, 1, 3) + value_power_on = 1 + value_power_off = 2 + + +class SNMPDriverCyberPower(SNMPDriverSimple): + """SNMP driver class for CyberPower PDU devices. + + SNMP objects for CyberPower PDU: + 1.3.6.1.4.1.3808.1.1.3.3.3.1.1.4 ePDUOutletControlOutletCommand + Values: 1=On, 2=Off, 3=PowerCycle, [...more options follow] + """ + + # NOTE(mgoddard): This device driver is currently untested, this driver has + # been implemented based upon its published MIB + # documentation. + + oid_device = (3808, 1, 1, 3, 3, 3, 1, 1, 4) + value_power_on = 1 + value_power_off = 2 + + +class SNMPDriverTeltronix(SNMPDriverSimple): + """SNMP driver class for Teltronix PDU devices. + + SNMP objects for Teltronix PDU: + 1.3.6.1.4.1.23620.1.2.2.1.4 Outlet Power + Values: 1=Off, 2=On + """ + + oid_device = (23620, 1, 2, 2, 1, 4) + value_power_on = 2 + value_power_off = 1 + + +class SNMPDriverEatonPower(SNMPDriverBase): + """SNMP driver class for Eaton Power PDU. + + The Eaton power PDU does not follow the model of SNMPDriverSimple as it + uses multiple SNMP objects. + + SNMP objects for Eaton Power PDU + 1.3.6.1.4.1.534.6.6.7.6.6.1.2. outletControlStatus + Read 0=off, 1=on, 2=pending off, 3=pending on + 1.3.6.1.4.1.534.6.6.7.6.6.1.3. outletControlOffCmd + Write 0 for immediate power off + 1.3.6.1.4.1.534.6.6.7.6.6.1.4. outletControlOnCmd + Write 0 for immediate power on + """ + + # NOTE(mgoddard): This device driver is currently untested, this driver has + # been implemented based upon its published MIB + # documentation. + + oid_device = (534, 6, 6, 7, 6, 6, 1) + oid_status = (2,) + oid_poweron = (3,) + oid_poweroff = (4,) + + status_off = 0 + status_on = 1 + status_pending_off = 2 + status_pending_on = 3 + + value_power_on = 0 + value_power_off = 0 + + def __init__(self, *args, **kwargs): + super(SNMPDriverEatonPower, self).__init__(*args, **kwargs) + # Due to its use of different OIDs for different actions, we only form + # an OID that holds the common substring of the OIDs for power + # operations. + self.oid_base = self.oid_enterprise + self.oid_device + + def _snmp_oid(self, oid): + """Return the OID for one of the outlet control objects. + + :param oid: The action-dependent portion of the OID, as a tuple of + integers. + :returns: The full OID as a tuple of integers. + """ + outlet = int(self.snmp_info['outlet']) + return self.oid_base + oid + (outlet,) + + def _snmp_power_state(self): + oid = self._snmp_oid(self.oid_status) + state = self.client.get(oid) + + # Translate the state to an Ironic power state. + if state in (self.status_on, self.status_pending_off): + power_state = states.POWER_ON + elif state in (self.status_off, self.status_pending_on): + power_state = states.POWER_OFF + else: + LOG.warning(_LW("Eaton Power SNMP PDU %(addr)s outlet %(outlet)s: " + "unrecognised power state %(state)s."), + {'addr': self.snmp_info['address'], + 'outlet': self.snmp_info['outlet'], + 'state': state}) + power_state = states.ERROR + + return power_state + + def _snmp_power_on(self): + oid = self._snmp_oid(self.oid_poweron) + value = rfc1902.Integer(self.value_power_on) + self.client.set(oid, value) + + def _snmp_power_off(self): + oid = self._snmp_oid(self.oid_poweroff) + value = rfc1902.Integer(self.value_power_off) + self.client.set(oid, value) + + +# A dictionary of supported drivers keyed by snmp_driver attribute +DRIVER_CLASSES = { + 'apc': SNMPDriverAPC, + 'cyberpower': SNMPDriverCyberPower, + 'eatonpower': SNMPDriverEatonPower, + 'teltronix': SNMPDriverTeltronix +} + + +def _parse_driver_info(node): + """Return a dictionary of validated driver information, usable for + SNMPDriver object creation. + + :param node: An Ironic node object. + :returns: SNMP driver info. + :raises: MissingParameterValue if any required parameters are missing. + :raises: InvalidParameterValue if any parameters 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(_( + "SNMP driver requires the following to be set: %s.") + % missing_info) + + snmp_info = {} + + # Validate PDU driver type + snmp_info['driver'] = info.get('snmp_driver') + if snmp_info['driver'] not in DRIVER_CLASSES: + raise exception.InvalidParameterValue(_( + "SNMPPowerDriver: unknown driver: '%s'") % snmp_info['driver']) + + # In absence of a version, default to SNMPv1 + snmp_info['version'] = info.get('snmp_version', SNMP_V1) + if snmp_info['version'] not in (SNMP_V1, SNMP_V2C, SNMP_V3): + raise exception.InvalidParameterValue(_( + "SNMPPowerDriver: unknown SNMP version: '%s'") % + snmp_info['version']) + + # In absence of a configured UDP port, default to the standard port + port_str = info.get('snmp_port', SNMP_PORT) + try: + snmp_info['port'] = int(port_str) + except ValueError: + raise exception.InvalidParameterValue(_( + "SNMPPowerDriver: SNMP UDP port must be numeric: %s") % port_str) + if snmp_info['port'] < 1 or snmp_info['port'] > 65535: + raise exception.InvalidParameterValue(_( + "SNMPPowerDriver: SNMP UDP port out of range: %d") + % snmp_info['port']) + + # Extract version-dependent required parameters + if snmp_info['version'] in (SNMP_V1, SNMP_V2C): + if 'snmp_community' not in info: + raise exception.MissingParameterValue(_( + "SNMP driver requires snmp_community to be set for version " + "%s.") % snmp_info['version']) + snmp_info['community'] = info.get('snmp_community') + elif snmp_info['version'] == SNMP_V3: + if 'snmp_security' not in info: + raise exception.MissingParameterValue(_( + "SNMP driver requires snmp_security to be set for version %s.") + % (SNMP_V3)) + snmp_info['security'] = info.get('snmp_security') + + # Target PDU IP address and power outlet identification + snmp_info['address'] = info.get('snmp_address') + snmp_info['outlet'] = info.get('snmp_outlet') + + return snmp_info + + +def _get_driver(node): + """Return a new SNMP driver object of the correct type for `node`. + + :param node: Single node object. + :raises: InvalidParameterValue if node power config is incomplete or + invalid. + :returns: SNMP driver object. + """ + snmp_info = _parse_driver_info(node) + cls = DRIVER_CLASSES[snmp_info['driver']] + return cls(snmp_info) + + +class SNMPPower(base.PowerInterface): + """SNMP Power Interface. + + This PowerInterface class provides a mechanism for controlling the power + state of a physical device using an SNMP-enabled smart power controller. + """ + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return COMMON_PROPERTIES + + def validate(self, task): + """Check that node.driver_info contains the requisite fields. + + :raises: MissingParameterValue if required SNMP parameters are missing. + :raises: InvalidParameterValue if SNMP parameters are invalid. + """ + _parse_driver_info(task.node) + + def get_power_state(self, task): + """Get the current power state. + + Poll the SNMP device for the current power state of the node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :raises: MissingParameterValue if required SNMP parameters are missing. + :raises: InvalidParameterValue if SNMP parameters are invalid. + :raises: SNMPFailure if an SNMP request fails. + :returns: power state. One of :class:`ironic.common.states`. + """ + driver = _get_driver(task.node) + power_state = driver.power_state() + return power_state + + @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 instance of `ironic.manager.task_manager.TaskManager`. + :param pstate: Either POWER_ON or POWER_OFF from :class: + `ironic.common.states`. + :raises: MissingParameterValue if required SNMP parameters are missing. + :raises: InvalidParameterValue if SNMP parameters are invalid or + `pstate` is invalid. + :raises: PowerStateFailure if the final power state of the node is not + as requested after the timeout. + :raises: SNMPFailure if an SNMP request fails. + """ + + driver = _get_driver(task.node) + if pstate == states.POWER_ON: + state = driver.power_on() + elif pstate == states.POWER_OFF: + state = driver.power_off() + else: + raise exception.InvalidParameterValue(_("set_power_state called " + "with invalid power " + "state %s.") % str(pstate)) + if state != pstate: + raise exception.PowerStateFailure(pstate=pstate) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """Cycles the power to a node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :raises: MissingParameterValue if required SNMP parameters are missing. + :raises: InvalidParameterValue if SNMP parameters are invalid. + :raises: PowerStateFailure if the final power state of the node is not + POWER_ON after the timeout. + :raises: SNMPFailure if an SNMP request fails. + """ + + driver = _get_driver(task.node) + state = driver.power_reset() + 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 afae4191c3..630e6a2534 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -28,6 +28,7 @@ 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 snmp from ironic.drivers.modules import ssh from ironic.drivers import utils @@ -154,3 +155,27 @@ class PXEAndIloDriver(base.BaseDriver): self.power = ilo_power.IloPower() self.deploy = pxe.PXEDeploy() self.vendor = pxe.VendorPassthru() + + +class PXEAndSNMPDriver(base.BaseDriver): + """PXE + SNMP driver. + + This driver implements the 'core' functionality, combining + :class:ironic.drivers.snmp.SNMP for power on/off and reboot with + :class:ironic.drivers.pxe.PXE for image deployment. Implentations are in + those respective classes; this class is merely the glue between them. + """ + + def __init__(self): + # Driver has a runtime dependency on PySNMP, abort load if it is absent + if not importutils.try_import('pysnmp'): + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason=_("Unable to import pysnmp library")) + self.power = snmp.SNMPPower() + self.deploy = pxe.PXEDeploy() + self.vendor = pxe.VendorPassthru() + + # PDUs have no boot device management capability. + # Only PXE as a boot device is supported. + self.management = None diff --git a/ironic/tests/conductor/test_manager.py b/ironic/tests/conductor/test_manager.py index 710bba25f8..9f84e8c635 100644 --- a/ironic/tests/conductor/test_manager.py +++ b/ironic/tests/conductor/test_manager.py @@ -2214,6 +2214,11 @@ class ManagerTestProperties(tests_db_base.DbTestCase): 'seamicro_api_version'] self._check_driver_properties("fake_seamicro", expected) + def test_driver_properties_fake_snmp(self): + expected = ['snmp_driver', 'snmp_address', 'snmp_port', 'snmp_version', + 'snmp_community', 'snmp_security', 'snmp_outlet'] + self._check_driver_properties("fake_snmp", expected) + def test_driver_properties_pxe_ipmitool(self): expected = ['ipmi_address', 'ipmi_terminal_port', 'ipmi_password', 'ipmi_priv_level', @@ -2243,6 +2248,12 @@ class ManagerTestProperties(tests_db_base.DbTestCase): 'seamicro_api_version'] self._check_driver_properties("pxe_seamicro", expected) + def test_driver_properties_pxe_snmp(self): + expected = ['pxe_deploy_kernel', 'pxe_deploy_ramdisk', + 'snmp_driver', 'snmp_address', 'snmp_port', 'snmp_version', + 'snmp_community', 'snmp_security', 'snmp_outlet'] + self._check_driver_properties("pxe_snmp", expected) + def test_driver_properties_fake_ilo(self): expected = ['ilo_address', 'ilo_username', 'ilo_password', 'client_port', 'client_timeout'] diff --git a/ironic/tests/db/utils.py b/ironic/tests/db/utils.py index 5979aa9da1..f55a108faa 100644 --- a/ironic/tests/db/utils.py +++ b/ironic/tests/db/utils.py @@ -124,6 +124,21 @@ def get_test_iboot_info(): } +def get_test_snmp_info(**kw): + result = { + "snmp_driver": kw.get("snmp_driver", "teltronix"), + "snmp_address": kw.get("snmp_address", "1.2.3.4"), + "snmp_port": kw.get("snmp_port", "161"), + "snmp_outlet": kw.get("snmp_outlet", "1"), + "snmp_version": kw.get("snmp_version", "1") + } + if result["snmp_version"] in ("1", "2c"): + result["snmp_community"] = kw.get("snmp_community", "public") + elif result["snmp_version"] == "3": + result["snmp_security"] = kw.get("snmp_security", "public") + return result + + def get_test_node(**kw): properties = { "cpu_arch": "x86_64", diff --git a/ironic/tests/drivers/test_snmp.py b/ironic/tests/drivers/test_snmp.py new file mode 100644 index 0000000000..6a2abbc885 --- /dev/null +++ b/ironic/tests/drivers/test_snmp.py @@ -0,0 +1,1007 @@ +# Copyright 2013,2014 Cray Inc +# +# Authors: David Hewson +# Stig Telfer +# Mark Goddard +# +# 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 SNMP power driver module.""" + +import mock + +from oslo.config import cfg + +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.db import api as db_api +from ironic.drivers.modules import snmp as snmp +from ironic.openstack.common import context +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 +from ironic.tests.objects import utils as obj_utils + +CONF = cfg.CONF +INFO_DICT = db_utils.get_test_snmp_info() + + +class SNMPValidateParametersTestCase(base.TestCase): + + def setUp(self): + super(SNMPValidateParametersTestCase, self).setUp() + self.context = context.get_admin_context() + + def _get_test_node(self, driver_info): + return obj_utils.get_test_node( + self.context, + driver_info=driver_info) + + def test__parse_driver_info_default(self): + # Make sure we get back the expected things. + node = self._get_test_node(INFO_DICT) + info = snmp._parse_driver_info(node) + self.assertEqual(INFO_DICT['snmp_driver'], info.get('driver')) + self.assertEqual(INFO_DICT['snmp_address'], info.get('address')) + self.assertEqual(INFO_DICT['snmp_port'], str(info.get('port'))) + self.assertEqual(INFO_DICT['snmp_outlet'], info.get('outlet')) + self.assertEqual(INFO_DICT['snmp_version'], info.get('version')) + self.assertEqual(INFO_DICT.get('snmp_community'), + info.get('community')) + self.assertEqual(INFO_DICT.get('snmp_security'), + info.get('security')) + + def test__parse_driver_info_apc(self): + # Make sure the APC driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='apc') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('apc', info.get('driver')) + + def test__parse_driver_info_cyberpower(self): + # Make sure the CyberPower driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='cyberpower') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('cyberpower', info.get('driver')) + + def test__parse_driver_info_eatonpower(self): + # Make sure the Eaton Power driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='eatonpower') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('eatonpower', info.get('driver')) + + def test__parse_driver_info_teltronix(self): + # Make sure the Teltronix driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='teltronix') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('teltronix', info.get('driver')) + + def test__parse_driver_info_snmp_v1(self): + # Make sure SNMPv1 is parsed with a community string. + info = db_utils.get_test_snmp_info(snmp_version='1', + snmp_community='public') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('1', info.get('version')) + self.assertEqual('public', info.get('community')) + + def test__parse_driver_info_snmp_v2c(self): + # Make sure SNMPv2c is parsed with a community string. + info = db_utils.get_test_snmp_info(snmp_version='2c', + snmp_community='private') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('2c', info.get('version')) + self.assertEqual('private', info.get('community')) + + def test__parse_driver_info_snmp_v3(self): + # Make sure SNMPv3 is parsed with a security string. + info = db_utils.get_test_snmp_info(snmp_version='3', + snmp_security='pass') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('3', info.get('version')) + self.assertEqual('pass', info.get('security')) + + def test__parse_driver_info_snmp_port_default(self): + # Make sure default SNMP UDP port numbers are correct + info = dict(INFO_DICT) + del info['snmp_port'] + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual(161, info.get('port')) + + def test__parse_driver_info_snmp_port(self): + # Make sure non-default SNMP UDP port numbers can be configured + info = db_utils.get_test_snmp_info(snmp_port='10161') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual(10161, info.get('port')) + + def test__parse_driver_info_missing_driver(self): + # Make sure exception is raised when the driver type is missing. + info = dict(INFO_DICT) + del info['snmp_driver'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_invalid_driver(self): + # Make sure exception is raised when the driver type is invalid. + info = db_utils.get_test_snmp_info(snmp_driver='invalidpower') + node = self._get_test_node(info) + self.assertRaises(exception.InvalidParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_missing_address(self): + # Make sure exception is raised when the address is missing. + info = dict(INFO_DICT) + del info['snmp_address'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_missing_outlet(self): + # Make sure exception is raised when the outlet is missing. + info = dict(INFO_DICT) + del info['snmp_outlet'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_default_version(self): + # Make sure version defaults to 1 when it is missing. + info = dict(INFO_DICT) + del info['snmp_version'] + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('1', info.get('version')) + self.assertEqual(INFO_DICT['snmp_community'], info.get('community')) + + def test__parse_driver_info_invalid_version(self): + # Make sure exception is raised when version is invalid. + info = db_utils.get_test_snmp_info(snmp_version='42', + snmp_community='public', + snmp_security='pass') + node = self._get_test_node(info) + self.assertRaises(exception.InvalidParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_default_version_and_missing_community(self): + # Make sure exception is raised when version and community are missing. + info = dict(INFO_DICT) + del info['snmp_version'] + del info['snmp_community'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_missing_community_snmp_v1(self): + # Make sure exception is raised when community is missing with SNMPv1. + info = dict(INFO_DICT) + del info['snmp_community'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_missing_community_snmp_v2c(self): + # Make sure exception is raised when community is missing with SNMPv2c. + info = db_utils.get_test_snmp_info(snmp_version='2c') + del info['snmp_community'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + def test__parse_driver_info_missing_security(self): + # Make sure exception is raised when security is missing with SNMPv3. + info = db_utils.get_test_snmp_info(snmp_version='3') + del info['snmp_security'] + node = self._get_test_node(info) + self.assertRaises(exception.MissingParameterValue, + snmp._parse_driver_info, + node) + + +@mock.patch.object(snmp, '_get_client') +class SNMPDeviceDriverTestCase(base.TestCase): + """Tests for the SNMP device-specific driver classes. + + The SNMP client object is mocked to allow various error cases to be tested. + """ + + def setUp(self): + super(SNMPDeviceDriverTestCase, self).setUp() + self.context = context.get_admin_context() + self.node = obj_utils.get_test_node( + self.context, + driver='fake_snmp', + driver_info=INFO_DICT) + + def _update_driver_info(self, **kwargs): + self.node["driver_info"].update(**kwargs) + + def _set_snmp_driver(self, snmp_driver): + self._update_driver_info(snmp_driver=snmp_driver) + + def _get_snmp_failure(self): + return exception.SNMPFailure(operation='test-operation', + error='test-error') + + def test_power_state_on(self, mock_get_client): + # Ensure the power on state is queried correctly + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_on + pstate = driver.power_state() + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_ON, pstate) + + def test_power_state_off(self, mock_get_client): + # Ensure the power off state is queried correctly + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_off + pstate = driver.power_state() + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_OFF, pstate) + + def test_power_state_error(self, mock_get_client): + # Ensure an unexpected power state returns an error + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = 42 + pstate = driver.power_state() + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.ERROR, pstate) + + def test_power_state_snmp_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a query are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_state) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + + def test_power_on(self, mock_get_client): + # Ensure the device is powered on correctly + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_on + pstate = driver.power_on() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_ON, pstate) + + def test_power_off(self, mock_get_client): + # Ensure the device is powered off correctly + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_off + pstate = driver.power_off() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_OFF, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_on_delay(self, mock_sleep, mock_get_client): + # Ensure driver waits for the state to change following a power on + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_off, + driver.value_power_on] + pstate = driver.power_on() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + calls = [mock.call(driver._snmp_oid())] * 2 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_off_delay(self, mock_sleep, mock_get_client): + # Ensure driver waits for the state to change following a power off + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_on, + driver.value_power_off] + pstate = driver.power_off() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + calls = [mock.call(driver._snmp_oid())] * 2 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_OFF, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_on_invalid_state(self, mock_sleep, mock_get_client): + # Ensure driver retries when querying unexpected states following a + # power on + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = 42 + pstate = driver.power_on() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + attempts = CONF.snmp.power_timeout // driver.retry_interval + calls = [mock.call(driver._snmp_oid())] * attempts + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_off_invalid_state(self, mock_sleep, mock_get_client): + # Ensure driver retries when querying unexpected states following a + # power off + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = 42 + pstate = driver.power_off() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + attempts = CONF.snmp.power_timeout // driver.retry_interval + calls = [mock.call(driver._snmp_oid())] * attempts + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + def test_power_on_snmp_set_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a power on set operation + # are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.set.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_on) + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + + def test_power_off_snmp_set_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a power off set + # operation are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.set.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_off) + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + + def test_power_on_snmp_get_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a power on get operation + # are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_on) + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + + def test_power_off_snmp_get_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a power off get + # operation are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_off) + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_on_timeout(self, mock_sleep, mock_get_client): + # Ensure that a power on consistency poll timeout causes an error + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_off + pstate = driver.power_on() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + attempts = CONF.snmp.power_timeout // driver.retry_interval + calls = [mock.call(driver._snmp_oid())] * attempts + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_off_timeout(self, mock_sleep, mock_get_client): + # Ensure that a power off consistency poll timeout causes an error + mock_client = mock_get_client.return_value + CONF.snmp.power_timeout = 5 + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_on + pstate = driver.power_off() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + attempts = CONF.snmp.power_timeout // driver.retry_interval + calls = [mock.call(driver._snmp_oid())] * attempts + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + def test_power_reset(self, mock_get_client): + # Ensure the device is reset correctly + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_off, + driver.value_power_on] + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * 2 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_off_delay(self, mock_sleep, mock_get_client): + # Ensure driver waits for the power off state change following a power + # reset + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_on, + driver.value_power_off, + driver.value_power_on] + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * 3 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_on_delay(self, mock_sleep, mock_get_client): + # Ensure driver waits for the power on state change following a power + # reset + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_off, + driver.value_power_off, + driver.value_power_on] + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * 3 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_off_delay_on_delay(self, mock_sleep, mock_get_client): + # Ensure driver waits for both state changes following a power reset + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_on, + driver.value_power_off, + driver.value_power_off, + driver.value_power_on] + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * 4 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_off_invalid_state(self, mock_sleep, mock_get_client): + # Ensure driver retries when querying unexpected states following a + # power off during a reset + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = 42 + pstate = driver.power_reset() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + attempts = CONF.snmp.power_timeout // driver.retry_interval + calls = [mock.call(driver._snmp_oid())] * attempts + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_on_invalid_state(self, mock_sleep, mock_get_client): + # Ensure driver retries when querying unexpected states following a + # power on during a reset + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + attempts = CONF.snmp.power_timeout // driver.retry_interval + mock_client.get.side_effect = ([driver.value_power_off] + + [42] * attempts) + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * (1 + attempts) + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_off_timeout(self, mock_sleep, mock_get_client): + # Ensure that a power off consistency poll timeout during a reset + # causes an error + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_on + pstate = driver.power_reset() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + attempts = CONF.snmp.power_timeout // driver.retry_interval + calls = [mock.call(driver._snmp_oid())] * attempts + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + @mock.patch("eventlet.greenthread.sleep") + def test_power_reset_on_timeout(self, mock_sleep, mock_get_client): + # Ensure that a power on consistency poll timeout during a reset + # causes an error + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + attempts = CONF.snmp.power_timeout // driver.retry_interval + mock_client.get.side_effect = ([driver.value_power_off] * + (1 + attempts)) + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * (1 + attempts) + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.ERROR, pstate) + + def test_power_reset_off_snmp_set_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a reset power off set + # operation are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.set.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_reset) + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + self.assertFalse(mock_client.get.called) + + def test_power_reset_off_snmp_get_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a reset power off get + # operation are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = self._get_snmp_failure() + self.assertRaises(exception.SNMPFailure, + driver.power_reset) + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + + def test_power_reset_on_snmp_set_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a reset power on set + # operation are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.set.side_effect = [None, self._get_snmp_failure()] + mock_client.get.return_value = driver.value_power_off + self.assertRaises(exception.SNMPFailure, + driver.power_reset) + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + + def test_power_reset_on_snmp_get_failure(self, mock_get_client): + # Ensure SNMP failure exceptions raised during a reset power on get + # operation are propagated + mock_client = mock_get_client.return_value + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_off, + self._get_snmp_failure()] + self.assertRaises(exception.SNMPFailure, + driver.power_reset) + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid()), mock.call(driver._snmp_oid())] + mock_client.get.assert_has_calls(calls) + + def _test_simple_device_power_state_on(self, snmp_driver, mock_get_client): + # Ensure a simple device driver queries power on correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver(snmp_driver) + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_on + pstate = driver.power_state() + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_ON, pstate) + + def _test_simple_device_power_state_off(self, snmp_driver, + mock_get_client): + # Ensure a simple device driver queries power off correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver(snmp_driver) + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_off + pstate = driver.power_state() + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_OFF, pstate) + + def _test_simple_device_power_on(self, snmp_driver, mock_get_client): + # Ensure a simple device driver powers on correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver(snmp_driver) + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_on + pstate = driver.power_on() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_on) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_ON, pstate) + + def _test_simple_device_power_off(self, snmp_driver, mock_get_client): + # Ensure a simple device driver powers off correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver(snmp_driver) + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.value_power_off + pstate = driver.power_off() + mock_client.set.assert_called_once_with(driver._snmp_oid(), + driver.value_power_off) + mock_client.get.assert_called_once_with(driver._snmp_oid()) + self.assertEqual(states.POWER_OFF, pstate) + + def _test_simple_device_power_reset(self, snmp_driver, mock_get_client): + # Ensure a simple device driver resets correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver(snmp_driver) + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.value_power_off, + driver.value_power_on] + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(), driver.value_power_off), + mock.call(driver._snmp_oid(), driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid())] * 2 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + def test_apc_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the APC + # driver + self._update_driver_info(snmp_driver="apc", + snmp_outlet="3") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 318, 1, 1, 4, 4, 2, 1, 3, 3) + self.assertEqual(oid, driver._snmp_oid()) + self.assertEqual(1, driver.value_power_on) + self.assertEqual(2, driver.value_power_off) + + def test_apc_power_state_on(self, mock_get_client): + self._test_simple_device_power_state_on('apc', mock_get_client) + + def test_apc_power_state_off(self, mock_get_client): + self._test_simple_device_power_state_off('apc', mock_get_client) + + def test_apc_power_on(self, mock_get_client): + self._test_simple_device_power_on('apc', mock_get_client) + + def test_apc_power_off(self, mock_get_client): + self._test_simple_device_power_off('apc', mock_get_client) + + def test_apc_power_reset(self, mock_get_client): + self._test_simple_device_power_reset('apc', mock_get_client) + + def test_cyberpower_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the + # CyberPower driver + self._update_driver_info(snmp_driver="cyberpower", + snmp_outlet="3") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 3808, 1, 1, 3, 3, 3, 1, 1, 4, 3) + self.assertEqual(oid, driver._snmp_oid()) + self.assertEqual(1, driver.value_power_on) + self.assertEqual(2, driver.value_power_off) + + def test_cyberpower_power_state_on(self, mock_get_client): + self._test_simple_device_power_state_on('cyberpower', mock_get_client) + + def test_cyberpower_power_state_off(self, mock_get_client): + self._test_simple_device_power_state_off('cyberpower', mock_get_client) + + def test_cyberpower_power_on(self, mock_get_client): + self._test_simple_device_power_on('cyberpower', mock_get_client) + + def test_cyberpower_power_off(self, mock_get_client): + self._test_simple_device_power_off('cyberpower', mock_get_client) + + def test_cyberpower_power_reset(self, mock_get_client): + self._test_simple_device_power_reset('cyberpower', mock_get_client) + + def test_teltronix_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the + # Teltronix driver + self._update_driver_info(snmp_driver="teltronix", + snmp_outlet="3") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 23620, 1, 2, 2, 1, 4, 3) + self.assertEqual(oid, driver._snmp_oid()) + self.assertEqual(2, driver.value_power_on) + self.assertEqual(1, driver.value_power_off) + + def test_teltronix_power_state_on(self, mock_get_client): + self._test_simple_device_power_state_on('teltronix', mock_get_client) + + def test_teltronix_power_state_off(self, mock_get_client): + self._test_simple_device_power_state_off('teltronix', mock_get_client) + + def test_teltronix_power_on(self, mock_get_client): + self._test_simple_device_power_on('teltronix', mock_get_client) + + def test_teltronix_power_off(self, mock_get_client): + self._test_simple_device_power_off('teltronix', mock_get_client) + + def test_teltronix_power_reset(self, mock_get_client): + self._test_simple_device_power_reset('teltronix', mock_get_client) + + def test_eaton_power_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the Eaton + # Power driver + self._update_driver_info(snmp_driver="eatonpower", + snmp_outlet="3") + driver = snmp._get_driver(self.node) + status_oid = (1, 3, 6, 1, 4, 1, 534, 6, 6, 7, 6, 6, 1, 2, 3) + poweron_oid = (1, 3, 6, 1, 4, 1, 534, 6, 6, 7, 6, 6, 1, 3, 3) + poweroff_oid = (1, 3, 6, 1, 4, 1, 534, 6, 6, 7, 6, 6, 1, 4, 3) + self.assertEqual(status_oid, driver._snmp_oid(driver.oid_status)) + self.assertEqual(poweron_oid, driver._snmp_oid(driver.oid_poweron)) + self.assertEqual(poweroff_oid, driver._snmp_oid(driver.oid_poweroff)) + self.assertEqual(0, driver.status_off) + self.assertEqual(1, driver.status_on) + self.assertEqual(2, driver.status_pending_off) + self.assertEqual(3, driver.status_pending_on) + + def test_eaton_power_power_state_on(self, mock_get_client): + # Ensure the Eaton Power driver queries on correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.status_on + pstate = driver.power_state() + mock_client.get.assert_called_once_with( + driver._snmp_oid(driver.oid_status)) + self.assertEqual(states.POWER_ON, pstate) + + def test_eaton_power_power_state_off(self, mock_get_client): + # Ensure the Eaton Power driver queries off correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.status_off + pstate = driver.power_state() + mock_client.get.assert_called_once_with( + driver._snmp_oid(driver.oid_status)) + self.assertEqual(states.POWER_OFF, pstate) + + def test_eaton_power_power_state_pending_off(self, mock_get_client): + # Ensure the Eaton Power driver queries pending off correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.status_pending_off + pstate = driver.power_state() + mock_client.get.assert_called_once_with( + driver._snmp_oid(driver.oid_status)) + self.assertEqual(states.POWER_ON, pstate) + + def test_eaton_power_power_state_pending_on(self, mock_get_client): + # Ensure the Eaton Power driver queries pending on correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.status_pending_on + pstate = driver.power_state() + mock_client.get.assert_called_once_with( + driver._snmp_oid(driver.oid_status)) + self.assertEqual(states.POWER_OFF, pstate) + + def test_eaton_power_power_on(self, mock_get_client): + # Ensure the Eaton Power driver powers on correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.status_on + pstate = driver.power_on() + mock_client.set.assert_called_once_with( + driver._snmp_oid(driver.oid_poweron), driver.value_power_on) + mock_client.get.assert_called_once_with( + driver._snmp_oid(driver.oid_status)) + self.assertEqual(states.POWER_ON, pstate) + + def test_eaton_power_power_off(self, mock_get_client): + # Ensure the Eaton Power driver powers off correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.return_value = driver.status_off + pstate = driver.power_off() + mock_client.set.assert_called_once_with( + driver._snmp_oid(driver.oid_poweroff), driver.value_power_off) + mock_client.get.assert_called_once_with( + driver._snmp_oid(driver.oid_status)) + self.assertEqual(states.POWER_OFF, pstate) + + def test_eaton_power_power_reset(self, mock_get_client): + # Ensure the Eaton Power driver resets correctly + mock_client = mock_get_client.return_value + self._set_snmp_driver("eatonpower") + driver = snmp._get_driver(self.node) + mock_client.get.side_effect = [driver.status_off, driver.status_on] + pstate = driver.power_reset() + calls = [mock.call(driver._snmp_oid(driver.oid_poweroff), + driver.value_power_off), + mock.call(driver._snmp_oid(driver.oid_poweron), + driver.value_power_on)] + mock_client.set.assert_has_calls(calls) + calls = [mock.call(driver._snmp_oid(driver.oid_status))] * 2 + mock_client.get.assert_has_calls(calls) + self.assertEqual(states.POWER_ON, pstate) + + +@mock.patch.object(snmp, '_get_driver') +class SNMPDriverTestCase(db_base.DbTestCase): + """SNMP power driver interface tests. + + In this test case, the SNMP power driver interface is exercised. The + device-specific SNMP driver is mocked to allow various error cases to be + tested. + """ + + def setUp(self): + super(SNMPDriverTestCase, self).setUp() + self.context = context.get_admin_context() + self.dbapi = db_api.get_instance() + mgr_utils.mock_the_extension_manager(driver='fake_snmp') + + self.node = obj_utils.create_test_node(self.context, + driver='fake_snmp', + driver_info=INFO_DICT) + + def _get_snmp_failure(self): + return exception.SNMPFailure(operation='test-operation', + error='test-error') + + def test_get_properties(self, mock_get_driver): + expected = snmp.COMMON_PROPERTIES + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertEqual(expected, task.driver.get_properties()) + + def test_get_power_state_on(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_state.return_value = states.POWER_ON + with task_manager.acquire(self.context, self.node.uuid) as task: + pstate = task.driver.power.get_power_state(task) + mock_driver.power_state.assert_called_once_with() + self.assertEqual(states.POWER_ON, pstate) + + def test_get_power_state_off(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_state.return_value = states.POWER_OFF + with task_manager.acquire(self.context, self.node.uuid) as task: + pstate = task.driver.power.get_power_state(task) + mock_driver.power_state.assert_called_once_with() + self.assertEqual(states.POWER_OFF, pstate) + + def test_get_power_state_error(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_state.return_value = states.ERROR + with task_manager.acquire(self.context, self.node.uuid) as task: + pstate = task.driver.power.get_power_state(task) + mock_driver.power_state.assert_called_once_with() + self.assertEqual(states.ERROR, pstate) + + def test_get_power_state_snmp_failure(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_state.side_effect = self._get_snmp_failure() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.SNMPFailure, + task.driver.power.get_power_state, task) + mock_driver.power_state.assert_called_once_with() + + def test_set_power_state_on(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_on.return_value = states.POWER_ON + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.set_power_state(task, states.POWER_ON) + mock_driver.power_on.assert_called_once_with() + + def test_set_power_state_off(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_off.return_value = states.POWER_OFF + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.set_power_state(task, states.POWER_OFF) + mock_driver.power_off.assert_called_once_with() + + def test_set_power_state_error(self, mock_get_driver): + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.set_power_state, + task, states.ERROR) + + def test_set_power_state_on_snmp_failure(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_on.side_effect = self._get_snmp_failure() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.SNMPFailure, + task.driver.power.set_power_state, + task, states.POWER_ON) + mock_driver.power_on.assert_called_once_with() + + def test_set_power_state_off_snmp_failure(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_off.side_effect = self._get_snmp_failure() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.SNMPFailure, + task.driver.power.set_power_state, + task, states.POWER_OFF) + mock_driver.power_off.assert_called_once_with() + + def test_set_power_state_on_timeout(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_on.return_value = states.ERROR + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.PowerStateFailure, + task.driver.power.set_power_state, + task, states.POWER_ON) + mock_driver.power_on.assert_called_once_with() + + def test_set_power_state_off_timeout(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_off.return_value = states.ERROR + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.PowerStateFailure, + task.driver.power.set_power_state, + task, states.POWER_OFF) + mock_driver.power_off.assert_called_once_with() + + def test_reboot(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_reset.return_value = states.POWER_ON + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.reboot(task) + mock_driver.power_reset.assert_called_once_with() + + def test_reboot_snmp_failure(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_reset.side_effect = self._get_snmp_failure() + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.SNMPFailure, + task.driver.power.reboot, task) + mock_driver.power_reset.assert_called_once_with() + + def test_reboot_timeout(self, mock_get_driver): + mock_driver = mock_get_driver.return_value + mock_driver.power_reset.return_value = states.ERROR + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.PowerStateFailure, + task.driver.power.reboot, task) + mock_driver.power_reset.assert_called_once_with() diff --git a/ironic/tests/drivers/third_party_driver_mocks.py b/ironic/tests/drivers/third_party_driver_mocks.py index 602d6d30a5..242b946f1c 100644 --- a/ironic/tests/drivers/third_party_driver_mocks.py +++ b/ironic/tests/drivers/third_party_driver_mocks.py @@ -24,6 +24,7 @@ Current list of mocked libraries: seamicroclient ipminative proliantutils + pysnmp """ import sys @@ -113,3 +114,27 @@ if not iboot: # external library has been mocked if 'ironic.drivers.modules.iboot' in sys.modules: reload(sys.modules['ironic.drivers.modules.iboot']) + + +# attempt to load the external 'pysnmp' library, which is required by +# the optional drivers.modules.snmp module +pysnmp = importutils.try_import("pysnmp") +if not pysnmp: + pysnmp = mock.Mock() + sys.modules["pysnmp"] = pysnmp + sys.modules["pysnmp.entity"] = pysnmp.entity + sys.modules["pysnmp.entity.rfc3413"] = pysnmp.entity.rfc3413 + sys.modules["pysnmp.entity.rfc3413.oneliner"] = ( + pysnmp.entity.rfc3413.oneliner) + sys.modules["pysnmp.entity.rfc3413.oneliner.cmdgen"] = ( + pysnmp.entity.rfc3413.oneliner.cmdgen) + sys.modules["pysnmp.proto"] = pysnmp.proto + sys.modules["pysnmp.proto.rfc1902"] = pysnmp.proto.rfc1902 + # Patch the RFC1902 integer class with a python int + pysnmp.proto.rfc1902.Integer = int + + +# if anything has loaded the snmp driver yet, reload it now that the +# external library has been mocked +if 'ironic.drivers.modules.snmp' in sys.modules: + reload(sys.modules['ironic.drivers.modules.snmp']) diff --git a/setup.cfg b/setup.cfg index e4e183046e..54774a2640 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,7 @@ ironic.drivers = fake_iboot = ironic.drivers.fake:FakeIBootDriver fake_ilo = ironic.drivers.fake:FakeIloDriver fake_drac = ironic.drivers.fake:FakeDracDriver + fake_snmp = ironic.drivers.fake:FakeSNMPDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver @@ -55,6 +56,7 @@ ironic.drivers = pxe_iboot = ironic.drivers.pxe:PXEAndIBootDriver pxe_ilo = ironic.drivers.pxe:PXEAndIloDriver pxe_drac = ironic.drivers.drac:PXEDracDriver + pxe_snmp = ironic.drivers.pxe:PXEAndSNMPDriver [pbr] autodoc_index_modules = True