Adds SNMP power driver
Add a new PowerDriver module for remote control of node power by enabling or disabling sockets on a rack power strip via SNMP. Implements: blueprint ironic-snmp-power-driver Change-Id: Ib16781eaa9737525c4d8a9231ecd9e72d7e2645e Co-authored-by: David Hewson <dhewson@cray.com> Co-authored-by: Mark Goddard <mgoddard@cray.com>
This commit is contained in:
parent
5592b1fa9a
commit
8a6b72030c
@ -12,3 +12,59 @@ DRAC with PXE deploy
|
|||||||
|
|
||||||
- Add ``pxe_drac`` to the list of ``enabled_drivers in`` ``/etc/ironic/ironic.conf``
|
- Add ``pxe_drac`` to the list of ``enabled_drivers in`` ``/etc/ironic/ironic.conf``
|
||||||
- Install openwsman-python package
|
- 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.
|
||||||
|
@ -1155,6 +1155,17 @@
|
|||||||
#action_timeout=10
|
#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]
|
[ssh]
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -459,3 +459,7 @@ class ImageCreationFailed(IronicException):
|
|||||||
|
|
||||||
class SwiftOperationError(IronicException):
|
class SwiftOperationError(IronicException):
|
||||||
message = _("Swift operation '%(operation)s' failed: %(error)s")
|
message = _("Swift operation '%(operation)s' failed: %(error)s")
|
||||||
|
|
||||||
|
|
||||||
|
class SNMPFailure(IronicException):
|
||||||
|
message = _("SNMP operation '%(operation)s' failed: %(error)s")
|
||||||
|
@ -32,6 +32,7 @@ from ironic.drivers.modules import ipminative
|
|||||||
from ironic.drivers.modules import ipmitool
|
from ironic.drivers.modules import ipmitool
|
||||||
from ironic.drivers.modules import pxe
|
from ironic.drivers.modules import pxe
|
||||||
from ironic.drivers.modules import seamicro
|
from ironic.drivers.modules import seamicro
|
||||||
|
from ironic.drivers.modules import snmp
|
||||||
from ironic.drivers.modules import ssh
|
from ironic.drivers.modules import ssh
|
||||||
from ironic.drivers import utils
|
from ironic.drivers import utils
|
||||||
|
|
||||||
@ -145,3 +146,15 @@ class FakeDracDriver(base.BaseDriver):
|
|||||||
self.power = drac_power.DracPower()
|
self.power = drac_power.DracPower()
|
||||||
self.deploy = fake.FakeDeploy()
|
self.deploy = fake.FakeDeploy()
|
||||||
self.management = drac_mgmt.DracManagement()
|
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()
|
||||||
|
657
ironic/drivers/modules/snmp.py
Normal file
657
ironic/drivers/modules/snmp.py
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
# Copyright 2013,2014 Cray Inc
|
||||||
|
#
|
||||||
|
# Authors: David Hewson <dhewson@cray.com>
|
||||||
|
# Stig Telfer <stelfer@cray.com>
|
||||||
|
# Mark Goddard <mgoddard@cray.com>
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
<enterprise OID>.<device OID>.<outlet ID>. 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.<outlet ID> 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.<outlet ID> outletControlOffCmd
|
||||||
|
Write 0 for immediate power off
|
||||||
|
1.3.6.1.4.1.534.6.6.7.6.6.1.4.<outlet ID> 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 <property name>:<property description> 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)
|
@ -28,6 +28,7 @@ from ironic.drivers.modules import ipminative
|
|||||||
from ironic.drivers.modules import ipmitool
|
from ironic.drivers.modules import ipmitool
|
||||||
from ironic.drivers.modules import pxe
|
from ironic.drivers.modules import pxe
|
||||||
from ironic.drivers.modules import seamicro
|
from ironic.drivers.modules import seamicro
|
||||||
|
from ironic.drivers.modules import snmp
|
||||||
from ironic.drivers.modules import ssh
|
from ironic.drivers.modules import ssh
|
||||||
from ironic.drivers import utils
|
from ironic.drivers import utils
|
||||||
|
|
||||||
@ -154,3 +155,27 @@ class PXEAndIloDriver(base.BaseDriver):
|
|||||||
self.power = ilo_power.IloPower()
|
self.power = ilo_power.IloPower()
|
||||||
self.deploy = pxe.PXEDeploy()
|
self.deploy = pxe.PXEDeploy()
|
||||||
self.vendor = pxe.VendorPassthru()
|
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
|
||||||
|
@ -2214,6 +2214,11 @@ class ManagerTestProperties(tests_db_base.DbTestCase):
|
|||||||
'seamicro_api_version']
|
'seamicro_api_version']
|
||||||
self._check_driver_properties("fake_seamicro", expected)
|
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):
|
def test_driver_properties_pxe_ipmitool(self):
|
||||||
expected = ['ipmi_address', 'ipmi_terminal_port',
|
expected = ['ipmi_address', 'ipmi_terminal_port',
|
||||||
'ipmi_password', 'ipmi_priv_level',
|
'ipmi_password', 'ipmi_priv_level',
|
||||||
@ -2243,6 +2248,12 @@ class ManagerTestProperties(tests_db_base.DbTestCase):
|
|||||||
'seamicro_api_version']
|
'seamicro_api_version']
|
||||||
self._check_driver_properties("pxe_seamicro", expected)
|
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):
|
def test_driver_properties_fake_ilo(self):
|
||||||
expected = ['ilo_address', 'ilo_username', 'ilo_password',
|
expected = ['ilo_address', 'ilo_username', 'ilo_password',
|
||||||
'client_port', 'client_timeout']
|
'client_port', 'client_timeout']
|
||||||
|
@ -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):
|
def get_test_node(**kw):
|
||||||
properties = {
|
properties = {
|
||||||
"cpu_arch": "x86_64",
|
"cpu_arch": "x86_64",
|
||||||
|
1007
ironic/tests/drivers/test_snmp.py
Normal file
1007
ironic/tests/drivers/test_snmp.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ Current list of mocked libraries:
|
|||||||
seamicroclient
|
seamicroclient
|
||||||
ipminative
|
ipminative
|
||||||
proliantutils
|
proliantutils
|
||||||
|
pysnmp
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@ -113,3 +114,27 @@ if not iboot:
|
|||||||
# external library has been mocked
|
# external library has been mocked
|
||||||
if 'ironic.drivers.modules.iboot' in sys.modules:
|
if 'ironic.drivers.modules.iboot' in sys.modules:
|
||||||
reload(sys.modules['ironic.drivers.modules.iboot'])
|
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'])
|
||||||
|
@ -48,6 +48,7 @@ ironic.drivers =
|
|||||||
fake_iboot = ironic.drivers.fake:FakeIBootDriver
|
fake_iboot = ironic.drivers.fake:FakeIBootDriver
|
||||||
fake_ilo = ironic.drivers.fake:FakeIloDriver
|
fake_ilo = ironic.drivers.fake:FakeIloDriver
|
||||||
fake_drac = ironic.drivers.fake:FakeDracDriver
|
fake_drac = ironic.drivers.fake:FakeDracDriver
|
||||||
|
fake_snmp = ironic.drivers.fake:FakeSNMPDriver
|
||||||
pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver
|
pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver
|
||||||
pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver
|
pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver
|
||||||
pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver
|
pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver
|
||||||
@ -55,6 +56,7 @@ ironic.drivers =
|
|||||||
pxe_iboot = ironic.drivers.pxe:PXEAndIBootDriver
|
pxe_iboot = ironic.drivers.pxe:PXEAndIBootDriver
|
||||||
pxe_ilo = ironic.drivers.pxe:PXEAndIloDriver
|
pxe_ilo = ironic.drivers.pxe:PXEAndIloDriver
|
||||||
pxe_drac = ironic.drivers.drac:PXEDracDriver
|
pxe_drac = ironic.drivers.drac:PXEDracDriver
|
||||||
|
pxe_snmp = ironic.drivers.pxe:PXEAndSNMPDriver
|
||||||
|
|
||||||
[pbr]
|
[pbr]
|
||||||
autodoc_index_modules = True
|
autodoc_index_modules = True
|
||||||
|
Loading…
x
Reference in New Issue
Block a user