Generic power interface for soft reboot and soft power off

This patch updates the generic power interface to support SOFT_REBOOT
and SOFT_POWER_OFF. And also it introduces "timeout" optional
parameter for all power operations.

Partial-Bug: #1526226
Change-Id: I1c9bbd1f11f6a8565607c874b3c99aa10eeb62a5
This commit is contained in:
Naohiro Tamura 2015-07-31 00:34:08 +09:00
parent 480d5be023
commit f15d5b9a37
19 changed files with 492 additions and 75 deletions

View File

@ -2,6 +2,12 @@
REST API Version History REST API Version History
======================== ========================
**1.27** (Ocata)
Add ``soft rebooting`` and ``soft power off`` as possible values
for the ``target`` field of the power state change payload, and
also add ``timeout`` field to it.
**1.26** (Ocata) **1.26** (Ocata)
Add portgroup ``mode`` and ``properties`` fields. Add portgroup ``mode`` and ``properties`` fields.

View File

@ -1013,6 +1013,12 @@
# disable timeout. (integer value) # disable timeout. (integer value)
#clean_callback_timeout = 1800 #clean_callback_timeout = 1800
# Timeout (in seconds) of soft reboot and soft power off
# operation. This value always has to be positive. (integer
# value)
# Minimum value: 1
#soft_power_off_timeout = 600
[console] [console]

View File

@ -102,6 +102,12 @@ PROVISION_ACTION_STATES = (ir_states.VERBS['manage'],
_NODES_CONTROLLER_RESERVED_WORDS = None _NODES_CONTROLLER_RESERVED_WORDS = None
ALLOWED_TARGET_POWER_STATES = (ir_states.POWER_ON,
ir_states.POWER_OFF,
ir_states.REBOOT,
ir_states.SOFT_REBOOT,
ir_states.SOFT_POWER_OFF)
def get_nodes_controller_reserved_names(): def get_nodes_controller_reserved_names():
global _NODES_CONTROLLER_RESERVED_WORDS global _NODES_CONTROLLER_RESERVED_WORDS
@ -434,16 +440,22 @@ class NodeStatesController(rest.RestController):
@METRICS.timer('NodeStatesController.power') @METRICS.timer('NodeStatesController.power')
@expose.expose(None, types.uuid_or_name, wtypes.text, @expose.expose(None, types.uuid_or_name, wtypes.text,
wtypes.IntegerType(minimum=1),
status_code=http_client.ACCEPTED) status_code=http_client.ACCEPTED)
def power(self, node_ident, target): def power(self, node_ident, target, timeout=None):
"""Set the power state of the node. """Set the power state of the node.
:param node_ident: the UUID or logical name of a node. :param node_ident: the UUID or logical name of a node.
:param target: The desired power state of the node. :param target: The desired power state of the node.
:param timeout: timeout (in seconds) positive integer (> 0) for any
power state. ``None`` indicates to use default timeout.
:raises: ClientSideError (HTTP 409) if a power operation is :raises: ClientSideError (HTTP 409) if a power operation is
already in progress. already in progress.
:raises: InvalidStateRequested (HTTP 400) if the requested target :raises: InvalidStateRequested (HTTP 400) if the requested target
state is not valid or if the node is in CLEANING state. state is not valid or if the node is in CLEANING state.
:raises: NotAcceptable (HTTP 406) for soft reboot, soft power off or
timeout parameter, if requested version of the API is less than 1.27.
:raises: Invalid (HTTP 400) if timeout value is less than 1.
""" """
cdict = pecan.request.context.to_policy_values() cdict = pecan.request.context.to_policy_values()
@ -454,9 +466,16 @@ class NodeStatesController(rest.RestController):
rpc_node = api_utils.get_rpc_node(node_ident) rpc_node = api_utils.get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node) topic = pecan.request.rpcapi.get_topic_for(rpc_node)
if target not in [ir_states.POWER_ON, if ((target in [ir_states.SOFT_REBOOT, ir_states.SOFT_POWER_OFF] or
ir_states.POWER_OFF, timeout) and not api_utils.allow_soft_power_off()):
ir_states.REBOOT]: raise exception.NotAcceptable()
# FIXME(naohirot): This check is workaround because
# wtypes.IntegerType(minimum=1) is not effective
if timeout is not None and timeout < 1:
raise exception.Invalid(
_("timeout has to be positive integer"))
if target not in ALLOWED_TARGET_POWER_STATES:
raise exception.InvalidStateRequested( raise exception.InvalidStateRequested(
action=target, node=node_ident, action=target, node=node_ident,
state=rpc_node.power_state) state=rpc_node.power_state)
@ -470,7 +489,8 @@ class NodeStatesController(rest.RestController):
pecan.request.rpcapi.change_node_power_state(pecan.request.context, pecan.request.rpcapi.change_node_power_state(pecan.request.context,
rpc_node.uuid, target, rpc_node.uuid, target,
topic) timeout=timeout,
topic=topic)
# Set the HTTP Location Header # Set the HTTP Location Header
url_args = '/'.join([node_ident, 'states']) url_args = '/'.join([node_ident, 'states'])
pecan.response.location = link.build_url('nodes', url_args) pecan.response.location = link.build_url('nodes', url_args)

View File

@ -369,6 +369,15 @@ def allow_raid_config():
return pecan.request.version.minor >= versions.MINOR_12_RAID_CONFIG return pecan.request.version.minor >= versions.MINOR_12_RAID_CONFIG
def allow_soft_power_off():
"""Check if Soft Power Off is allowed for the node.
Version 1.27 of the API allows Soft Power Off, including Soft Reboot, for
the node.
"""
return pecan.request.version.minor >= versions.MINOR_27_SOFT_POWER_OFF
def allow_links_node_states_and_driver_properties(): def allow_links_node_states_and_driver_properties():
"""Check if links are displayable. """Check if links are displayable.

View File

@ -57,6 +57,7 @@ BASE_VERSION = 1
# Add port.portgroup_uuid field. # Add port.portgroup_uuid field.
# v1.25: Add possibility to unset chassis_uuid from node. # v1.25: Add possibility to unset chassis_uuid from node.
# v1.26: Add portgroup.mode and portgroup.properties. # v1.26: Add portgroup.mode and portgroup.properties.
# v1.27: Add soft reboot, soft power off and timeout.
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -85,11 +86,12 @@ MINOR_23_PORTGROUPS = 23
MINOR_24_PORTGROUPS_SUBCONTROLLERS = 24 MINOR_24_PORTGROUPS_SUBCONTROLLERS = 24
MINOR_25_UNSET_CHASSIS_UUID = 25 MINOR_25_UNSET_CHASSIS_UUID = 25
MINOR_26_PORTGROUP_MODE_PROPERTIES = 26 MINOR_26_PORTGROUP_MODE_PROPERTIES = 26
MINOR_27_SOFT_POWER_OFF = 27
# When adding another version, update MINOR_MAX_VERSION and also update # When adding another version, update MINOR_MAX_VERSION and also update
# doc/source/dev/webapi-version-history.rst with a detailed explanation of # doc/source/dev/webapi-version-history.rst with a detailed explanation of
# what the version has changed. # what the version has changed.
MINOR_MAX_VERSION = MINOR_26_PORTGROUP_MODE_PROPERTIES MINOR_MAX_VERSION = MINOR_27_SOFT_POWER_OFF
# String representations of the minor and maximum versions # String representations of the minor and maximum versions
MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -212,6 +212,12 @@ POWER_OFF = 'power off'
REBOOT = 'rebooting' REBOOT = 'rebooting'
""" Node is rebooting. """ """ Node is rebooting. """
SOFT_REBOOT = 'soft rebooting'
""" Node is rebooting gracefully. """
SOFT_POWER_OFF = 'soft power off'
""" Node is in the process of soft power off. """
##################### #####################
# State machine model # State machine model

View File

@ -83,7 +83,7 @@ class ConductorManager(base_manager.BaseConductorManager):
"""Ironic Conductor manager main class.""" """Ironic Conductor manager main class."""
# NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's. # NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's.
RPC_API_VERSION = '1.38' RPC_API_VERSION = '1.39'
target = messaging.Target(version=RPC_API_VERSION) target = messaging.Target(version=RPC_API_VERSION)
@ -179,7 +179,8 @@ class ConductorManager(base_manager.BaseConductorManager):
@messaging.expected_exceptions(exception.InvalidParameterValue, @messaging.expected_exceptions(exception.InvalidParameterValue,
exception.NoFreeConductorWorker, exception.NoFreeConductorWorker,
exception.NodeLocked) exception.NodeLocked)
def change_node_power_state(self, context, node_id, new_state): def change_node_power_state(self, context, node_id, new_state,
timeout=None):
"""RPC method to encapsulate changes to a node's state. """RPC method to encapsulate changes to a node's state.
Perform actions such as power on, power off. The validation is Perform actions such as power on, power off. The validation is
@ -191,8 +192,12 @@ class ConductorManager(base_manager.BaseConductorManager):
:param context: an admin context. :param context: an admin context.
:param node_id: the id or uuid of a node. :param node_id: the id or uuid of a node.
:param new_state: the desired power state of the node. :param new_state: the desired power state of the node.
:param timeout: timeout (in seconds) positive integer (> 0) for any
power state. ``None`` indicates to use default timeout.
:raises: NoFreeConductorWorker when there is no free worker to start :raises: NoFreeConductorWorker when there is no free worker to start
async task. async task.
:raises: InvalidParameterValue
:raises: MissingParameterValue
""" """
LOG.debug("RPC change_node_power_state called for node %(node)s. " LOG.debug("RPC change_node_power_state called for node %(node)s. "
@ -202,19 +207,38 @@ class ConductorManager(base_manager.BaseConductorManager):
with task_manager.acquire(context, node_id, shared=False, with task_manager.acquire(context, node_id, shared=False,
purpose='changing node power state') as task: purpose='changing node power state') as task:
task.driver.power.validate(task) task.driver.power.validate(task)
if (new_state not in
task.driver.power.get_supported_power_states(task)):
# FIXME(naohirot):
# After driver composition, we should print power interface
# name here instead of driver.
raise exception.InvalidParameterValue(
_('The driver %(driver)s does not support the power state,'
' %(state)s') %
{'driver': task.node.driver, 'state': new_state})
if new_state in (states.SOFT_REBOOT, states.SOFT_POWER_OFF):
power_timeout = (timeout or
CONF.conductor.soft_power_off_timeout)
else:
power_timeout = timeout
# Set the target_power_state and clear any last_error, since we're # Set the target_power_state and clear any last_error, since we're
# starting a new operation. This will expose to other processes # starting a new operation. This will expose to other processes
# and clients that work is in progress. # and clients that work is in progress.
if new_state == states.REBOOT: if new_state in (states.POWER_ON, states.REBOOT,
states.SOFT_REBOOT):
task.node.target_power_state = states.POWER_ON task.node.target_power_state = states.POWER_ON
else: else:
task.node.target_power_state = new_state task.node.target_power_state = states.POWER_OFF
task.node.last_error = None task.node.last_error = None
task.node.save() task.node.save()
task.set_spawn_error_hook(utils.power_state_error_handler, task.set_spawn_error_hook(utils.power_state_error_handler,
task.node, task.node.power_state) task.node, task.node.power_state)
task.spawn_after(self._spawn_worker, utils.node_power_action, task.spawn_after(self._spawn_worker, utils.node_power_action,
task, new_state) task, new_state, timeout=power_timeout)
@METRICS.timer('ConductorManager.vendor_passthru') @METRICS.timer('ConductorManager.vendor_passthru')
@messaging.expected_exceptions(exception.NoFreeConductorWorker, @messaging.expected_exceptions(exception.NoFreeConductorWorker,

View File

@ -85,11 +85,12 @@ class ConductorAPI(object):
| 1.36 - Added create_node | 1.36 - Added create_node
| 1.37 - Added destroy_volume_target and update_volume_target | 1.37 - Added destroy_volume_target and update_volume_target
| 1.38 - Added vif_attach, vif_detach, vif_list | 1.38 - Added vif_attach, vif_detach, vif_list
| 1.39 - Added timeout optional parameter to change_node_power_state
""" """
# NOTE(rloo): This must be in sync with manager.ConductorManager's. # NOTE(rloo): This must be in sync with manager.ConductorManager's.
RPC_API_VERSION = '1.38' RPC_API_VERSION = '1.39'
def __init__(self, topic=None): def __init__(self, topic=None):
super(ConductorAPI, self).__init__() super(ConductorAPI, self).__init__()
@ -179,7 +180,8 @@ class ConductorAPI(object):
cctxt = self.client.prepare(topic=topic or self.topic, version='1.1') cctxt = self.client.prepare(topic=topic or self.topic, version='1.1')
return cctxt.call(context, 'update_node', node_obj=node_obj) return cctxt.call(context, 'update_node', node_obj=node_obj)
def change_node_power_state(self, context, node_id, new_state, topic=None): def change_node_power_state(self, context, node_id, new_state,
topic=None, timeout=None):
"""Change a node's power state. """Change a node's power state.
Synchronously, acquire lock and start the conductor background task Synchronously, acquire lock and start the conductor background task
@ -188,14 +190,16 @@ class ConductorAPI(object):
:param context: request context. :param context: request context.
:param node_id: node id or uuid. :param node_id: node id or uuid.
:param new_state: one of ironic.common.states power state values :param new_state: one of ironic.common.states power state values
:param timeout: timeout (in seconds) positive integer (> 0) for any
power state. ``None`` indicates to use default timeout.
:param topic: RPC topic. Defaults to self.topic. :param topic: RPC topic. Defaults to self.topic.
:raises: NoFreeConductorWorker when there is no free worker to start :raises: NoFreeConductorWorker when there is no free worker to start
async task. async task.
""" """
cctxt = self.client.prepare(topic=topic or self.topic, version='1.6') cctxt = self.client.prepare(topic=topic or self.topic, version='1.39')
return cctxt.call(context, 'change_node_power_state', node_id=node_id, return cctxt.call(context, 'change_node_power_state', node_id=node_id,
new_state=new_state) new_state=new_state, timeout=timeout)
def vendor_passthru(self, context, node_id, driver_method, http_method, def vendor_passthru(self, context, node_id, driver_method, http_method,
info, topic=None): info, topic=None):

View File

@ -15,6 +15,7 @@
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log from oslo_log import log
from oslo_utils import excutils from oslo_utils import excutils
from oslo_utils import reflection
from ironic.common import exception from ironic.common import exception
from ironic.common.i18n import _, _LE, _LI, _LW from ironic.common.i18n import _, _LE, _LI, _LW
@ -67,15 +68,15 @@ def node_set_boot_device(task, device, persistent=False):
@task_manager.require_exclusive_lock @task_manager.require_exclusive_lock
def node_power_action(task, new_state): def node_power_action(task, new_state, timeout=None):
"""Change power state or reset for a node. """Change power state or reset for a node.
Perform the requested power action if the transition is required. Perform the requested power action if the transition is required.
:param task: a TaskManager instance containing the node to act on. :param task: a TaskManager instance containing the node to act on.
:param new_state: Any power state from ironic.common.states. If the :param new_state: Any power state from ironic.common.states.
state is 'REBOOT' then a reboot will be attempted, otherwise :param timeout: timeout (in seconds) positive integer (> 0) for any
the node power state is directly set to 'state'. power state. ``None`` indicates to use default timeout.
:raises: InvalidParameterValue when the wrong state is specified :raises: InvalidParameterValue when the wrong state is specified
or the wrong driver info is specified. or the wrong driver info is specified.
:raises: other exceptions by the node's power driver if something :raises: other exceptions by the node's power driver if something
@ -86,50 +87,63 @@ def node_power_action(task, new_state):
task, fields.NotificationLevel.INFO, fields.NotificationStatus.START, task, fields.NotificationLevel.INFO, fields.NotificationStatus.START,
new_state) new_state)
node = task.node node = task.node
target_state = states.POWER_ON if new_state == states.REBOOT else new_state
if new_state != states.REBOOT: if new_state in (states.POWER_ON, states.REBOOT, states.SOFT_REBOOT):
try: target_state = states.POWER_ON
curr_state = task.driver.power.get_power_state(task) elif new_state in (states.POWER_OFF, states.SOFT_POWER_OFF):
except Exception as e: target_state = states.POWER_OFF
with excutils.save_and_reraise_exception(): else:
node['last_error'] = _( target_state = None
"Failed to change power state to '%(target)s'. "
"Error: %(error)s") % {'target': new_state, 'error': e}
node['target_power_state'] = states.NOSTATE
node.save()
notify_utils.emit_power_set_notification(
task, fields.NotificationLevel.ERROR,
fields.NotificationStatus.ERROR, new_state)
if curr_state == new_state: def _not_going_to_change():
# Neither the ironic service nor the hardware has erred. The # Neither the ironic service nor the hardware has erred. The
# node is, for some reason, already in the requested state, # node is, for some reason, already in the requested state,
# though we don't know why. eg, perhaps the user previously # though we don't know why. eg, perhaps the user previously
# requested the node POWER_ON, the network delayed those IPMI # requested the node POWER_ON, the network delayed those IPMI
# packets, and they are trying again -- but the node finally # packets, and they are trying again -- but the node finally
# responds to the first request, and so the second request # responds to the first request, and so the second request
# gets to this check and stops. # gets to this check and stops.
# This isn't an error, so we'll clear last_error field # This isn't an error, so we'll clear last_error field
# (from previous operation), log a warning, and return. # (from previous operation), log a warning, and return.
node['last_error'] = None node['last_error'] = None
# NOTE(dtantsur): under rare conditions we can get out of sync here # NOTE(dtantsur): under rare conditions we can get out of sync here
node['power_state'] = new_state node['power_state'] = curr_state
node['target_power_state'] = states.NOSTATE
node.save()
notify_utils.emit_power_set_notification(
task, fields.NotificationLevel.INFO,
fields.NotificationStatus.END, new_state)
LOG.warning(_LW("Not going to change node %(node)s power "
"state because current state = requested state "
"= '%(state)s'."),
{'node': node.uuid, 'state': curr_state})
try:
curr_state = task.driver.power.get_power_state(task)
except Exception as e:
with excutils.save_and_reraise_exception():
node['last_error'] = _(
"Failed to change power state to '%(target)s'. "
"Error: %(error)s") % {'target': new_state, 'error': e}
node['target_power_state'] = states.NOSTATE node['target_power_state'] = states.NOSTATE
node.save() node.save()
notify_utils.emit_power_set_notification( notify_utils.emit_power_set_notification(
task, fields.NotificationLevel.INFO, task, fields.NotificationLevel.ERROR,
fields.NotificationStatus.END, new_state) fields.NotificationStatus.ERROR, new_state)
LOG.warning(_LW("Not going to change node %(node)s power "
"state because current state = requested state "
"= '%(state)s'."),
{'node': node.uuid, 'state': curr_state})
return
if curr_state == states.ERROR: if curr_state == states.POWER_ON:
# be optimistic and continue action if new_state == states.POWER_ON:
LOG.warning(_LW("Driver returns ERROR power state for node %s."), _not_going_to_change()
node.uuid) return
elif curr_state == states.POWER_OFF:
if new_state in (states.POWER_OFF, states.SOFT_POWER_OFF):
_not_going_to_change()
return
else:
# if curr_state == states.ERROR:
# be optimistic and continue action
LOG.warning(_LW("Driver returns ERROR power state for node %s."),
node.uuid)
# Set the target_power_state and clear any last_error, if we're # Set the target_power_state and clear any last_error, if we're
# starting a new operation. This will expose to other processes # starting a new operation. This will expose to other processes
@ -142,15 +156,37 @@ def node_power_action(task, new_state):
# take power action # take power action
try: try:
if new_state != states.REBOOT: if new_state != states.REBOOT:
task.driver.power.set_power_state(task, new_state) if ('timeout' in reflection.get_signature(
task.driver.power.set_power_state).parameters):
task.driver.power.set_power_state(task, new_state,
timeout=timeout)
else:
# FIXME(naohirot):
# After driver composition, we should print power interface
# name here instead of driver.
LOG.warning(
_LW("The set_power_state method of %s(driver_name)s "
"doesn't support 'timeout' parameter."),
{'driver_name': node.driver})
task.driver.power.set_power_state(task, new_state)
else: else:
task.driver.power.reboot(task) if ('timeout' in reflection.get_signature(
task.driver.power.reboot).parameters):
task.driver.power.reboot(task, timeout=timeout)
else:
LOG.warning(_LW("The reboot method of %s(driver_name)s "
"doesn't support 'timeout' parameter."),
{'driver_name': node.driver})
task.driver.power.reboot(task)
except Exception as e: except Exception as e:
with excutils.save_and_reraise_exception(): with excutils.save_and_reraise_exception():
node['target_power_state'] = states.NOSTATE node['target_power_state'] = states.NOSTATE
node['last_error'] = _( node['last_error'] = _(
"Failed to change power state to '%(target)s'. " "Failed to change power state to '%(target_state)s' "
"Error: %(error)s") % {'target': target_state, 'error': e} "by '%(new_state)s'. Error: %(error)s") % {
'target_state': target_state,
'new_state': new_state,
'error': e}
node.save() node.save()
notify_utils.emit_power_set_notification( notify_utils.emit_power_set_notification(
task, fields.NotificationLevel.ERROR, task, fields.NotificationLevel.ERROR,
@ -164,8 +200,10 @@ def node_power_action(task, new_state):
task, fields.NotificationLevel.INFO, fields.NotificationStatus.END, task, fields.NotificationLevel.INFO, fields.NotificationStatus.END,
new_state) new_state)
LOG.info(_LI('Successfully set node %(node)s power state to ' LOG.info(_LI('Successfully set node %(node)s power state to '
'%(state)s.'), '%(target_state)s by %(new_state)s.'),
{'node': node.uuid, 'state': target_state}) {'node': node.uuid,
'target_state': target_state,
'new_state': new_state})
@task_manager.require_exclusive_lock @task_manager.require_exclusive_lock

View File

@ -131,6 +131,11 @@ opts = [
'ramdisk doing the cleaning. If the timeout is reached ' 'ramdisk doing the cleaning. If the timeout is reached '
'the node will be put in the "clean failed" provision ' 'the node will be put in the "clean failed" provision '
'state. Set to 0 to disable timeout.')), 'state. Set to 0 to disable timeout.')),
cfg.IntOpt('soft_power_off_timeout',
default=600,
min=1,
help=_('Timeout (in seconds) of soft reboot and soft power '
'off operation. This value always has to be positive.')),
] ]

View File

@ -31,6 +31,7 @@ import six
from ironic.common import exception from ironic.common import exception
from ironic.common.i18n import _, _LE, _LW from ironic.common.i18n import _, _LE, _LW
from ironic.common import raid from ironic.common import raid
from ironic.common import states
from ironic.drivers.modules.network import common as net_common from ironic.drivers.modules.network import common as net_common
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -493,25 +494,38 @@ class PowerInterface(BaseInterface):
""" """
@abc.abstractmethod @abc.abstractmethod
def set_power_state(self, task, power_state): def set_power_state(self, task, power_state, timeout=None):
"""Set the power state of the task's node. """Set the power state of the task's node.
:param task: a TaskManager instance containing the node to act on. :param task: a TaskManager instance containing the node to act on.
:param power_state: Any power state from :mod:`ironic.common.states`. :param power_state: Any power state from :mod:`ironic.common.states`.
:param timeout: timeout (in seconds) positive integer (> 0) for any
power state. ``None`` indicates to use default timeout.
:raises: MissingParameterValue if a required parameter is missing. :raises: MissingParameterValue if a required parameter is missing.
""" """
@abc.abstractmethod @abc.abstractmethod
def reboot(self, task): def reboot(self, task, timeout=None):
"""Perform a hard reboot of the task's node. """Perform a hard reboot of the task's node.
Drivers are expected to properly handle case when node is powered off Drivers are expected to properly handle case when node is powered off
by powering it on. by powering it on.
:param task: a TaskManager instance containing the node to act on. :param task: a TaskManager instance containing the node to act on.
:param timeout: timeout (in seconds) positive integer (> 0) for any
power state. ``None`` indicates to use default timeout.
:raises: MissingParameterValue if a required parameter is missing. :raises: MissingParameterValue if a required parameter is missing.
""" """
def get_supported_power_states(self, task):
"""Get a list of the supported power states.
:param task: A TaskManager instance containing the node to act on.
:returns: A list with the supported power states defined
in :mod:`ironic.common.states`.
"""
return [states.POWER_ON, states.POWER_OFF, states.REBOOT]
class ConsoleInterface(BaseInterface): class ConsoleInterface(BaseInterface):
"""Interface for console-related actions.""" """Interface for console-related actions."""

View File

@ -78,6 +78,14 @@ class FakeDriver(base.BaseDriver):
self.raid = fake.FakeRAID() self.raid = fake.FakeRAID()
class FakeSoftPowerDriver(FakeDriver):
"""Example implementation of a Driver."""
def __init__(self):
super(FakeSoftPowerDriver, self).__init__()
self.power = fake.FakeSoftPower()
class FakeIPMIToolDriver(base.BaseDriver): class FakeIPMIToolDriver(base.BaseDriver):
"""Example implementation of a Driver.""" """Example implementation of a Driver."""

View File

@ -46,7 +46,7 @@ class FakePower(base.PowerInterface):
def set_power_state(self, task, power_state): def set_power_state(self, task, power_state):
if power_state not in [states.POWER_ON, states.POWER_OFF]: if power_state not in [states.POWER_ON, states.POWER_OFF]:
raise exception.InvalidParameterValue( raise exception.InvalidParameterValue(
_("set_power_state called with an invalid power" _("set_power_state called with an invalid power "
"state: %s.") % power_state) "state: %s.") % power_state)
task.node.power_state = power_state task.node.power_state = power_state
@ -54,6 +54,25 @@ class FakePower(base.PowerInterface):
pass pass
class FakeSoftPower(FakePower):
"""Example implementation of a simple soft power operations."""
def set_power_state(self, task, power_state, timeout=None):
if power_state not in [states.POWER_ON, states.POWER_OFF,
states.SOFT_REBOOT, states.SOFT_POWER_OFF]:
raise exception.InvalidParameterValue(
_("set_power_state called with an invalid power "
"state: %s.") % power_state)
task.node.power_state = power_state
def reboot(self, task, timeout=None):
pass
def get_supported_power_states(self, task):
return [states.POWER_ON, states.POWER_OFF, states.REBOOT,
states.SOFT_REBOOT, states.SOFT_POWER_OFF]
class FakeBoot(base.BootInterface): class FakeBoot(base.BootInterface):
"""Example implementation of a simple boot interface.""" """Example implementation of a simple boot interface."""

View File

@ -2345,21 +2345,120 @@ class TestPut(test_api_base.BaseApiTest):
self.mock_dnih = p.start() self.mock_dnih = p.start()
self.addCleanup(p.stop) self.addCleanup(p.stop)
def test_power_state(self): def _test_power_state_success(self, target_state, timeout, api_version):
response = self.put_json('/nodes/%s/states/power' % self.node.uuid, if timeout is None:
{'target': states.POWER_ON}) body = {'target': target_state}
else:
body = {'target': target_state, 'timeout': timeout}
if api_version is None:
response = self.put_json(
'/nodes/%s/states/power' % self.node.uuid, body)
else:
response = self.put_json(
'/nodes/%s/states/power' % self.node.uuid, body,
headers={api_base.Version.string: api_version})
self.assertEqual(http_client.ACCEPTED, response.status_code) self.assertEqual(http_client.ACCEPTED, response.status_code)
self.assertEqual(b'', response.body) self.assertEqual(b'', response.body)
self.mock_cnps.assert_called_once_with(mock.ANY, self.mock_cnps.assert_called_once_with(mock.ANY,
self.node.uuid, self.node.uuid,
states.POWER_ON, target_state,
'test-topic') timeout=timeout,
topic='test-topic')
# Check location header # Check location header
self.assertIsNotNone(response.location) self.assertIsNotNone(response.location)
expected_location = '/v1/nodes/%s/states' % self.node.uuid expected_location = '/v1/nodes/%s/states' % self.node.uuid
self.assertEqual(urlparse.urlparse(response.location).path, self.assertEqual(urlparse.urlparse(response.location).path,
expected_location) expected_location)
def _test_power_state_failure(self, target_state, http_status_code,
timeout, api_version):
if timeout is None:
body = {'target': target_state}
else:
body = {'target': target_state, 'timeout': timeout}
if api_version is None:
response = self.put_json(
'/nodes/%s/states/power' % self.node.uuid, body,
expect_errors=True)
else:
response = self.put_json(
'/nodes/%s/states/power' % self.node.uuid, body,
headers={api_base.Version.string: api_version},
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_status_code, response.status_code)
self.assertTrue(response.json['error_message'])
def test_power_state_power_on_no_timeout_no_ver(self):
self._test_power_state_success(states.POWER_ON, None, None)
def test_power_state_power_on_no_timeout_valid_soft_ver(self):
self._test_power_state_success(states.POWER_ON, None, "1.27")
def test_power_state_power_on_no_timeout_invalid_soft_ver(self):
self._test_power_state_success(states.POWER_ON, None, "1.26")
def test_power_state_power_on_valid_timeout_no_ver(self):
self._test_power_state_failure(
states.POWER_ON, http_client.NOT_ACCEPTABLE, 2, None)
def test_power_state_power_on_valid_timeout_valid_soft_ver(self):
self._test_power_state_success(states.POWER_ON, 2, "1.27")
def test_power_state_power_on_valid_timeout_invalid_soft_ver(self):
self._test_power_state_failure(
states.POWER_ON, http_client.NOT_ACCEPTABLE, 2, "1.26")
def test_power_state_power_on_invalid_timeout_no_ver(self):
self._test_power_state_failure(
states.POWER_ON, http_client.BAD_REQUEST, 0, None)
def test_power_state_power_on_invalid_timeout_valid_soft_ver(self):
self._test_power_state_failure(
states.POWER_ON, http_client.BAD_REQUEST, 0, "1.27")
def test_power_state_power_on_invalid_timeout_invalid_soft_ver(self):
self._test_power_state_failure(
states.POWER_ON, http_client.BAD_REQUEST, 0, "1.26")
def test_power_state_soft_power_off_no_timeout_no_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.NOT_ACCEPTABLE, None, None)
def test_power_state_soft_power_off_no_timeout_valid_soft_ver(self):
self._test_power_state_success(states.SOFT_POWER_OFF, None, "1.27")
def test_power_state_soft_power_off_no_timeout_invalid_soft_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.NOT_ACCEPTABLE, None, "1.26")
def test_power_state_soft_power_off_valid_timeout_no_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.NOT_ACCEPTABLE, 2, None)
def test_power_state_soft_power_off_valid_timeout_valid_soft_ver(self):
self._test_power_state_success(states.SOFT_POWER_OFF, 2, "1.27")
def test_power_state_soft_power_off_valid_timeout_invalid_soft_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.NOT_ACCEPTABLE, 2, "1.26")
def test_power_state_soft_power_off_invalid_timeout_no_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.NOT_ACCEPTABLE, 0, None)
def test_power_state_soft_power_off_invalid_timeout_valid_soft_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.BAD_REQUEST, 0, "1.27")
def test_power_state_soft_power_off_invalid_timeout_invalid_soft_ver(self):
self._test_power_state_failure(
states.SOFT_POWER_OFF, http_client.NOT_ACCEPTABLE, 0, "1.26")
def test_power_state_by_name_unsupported(self): def test_power_state_by_name_unsupported(self):
response = self.put_json('/nodes/%s/states/power' % self.node.name, response = self.put_json('/nodes/%s/states/power' % self.node.name,
{'target': states.POWER_ON}, {'target': states.POWER_ON},
@ -2375,7 +2474,8 @@ class TestPut(test_api_base.BaseApiTest):
self.mock_cnps.assert_called_once_with(mock.ANY, self.mock_cnps.assert_called_once_with(mock.ANY,
self.node.uuid, self.node.uuid,
states.POWER_ON, states.POWER_ON,
'test-topic') timeout=None,
topic='test-topic')
# Check location header # Check location header
self.assertIsNotNone(response.location) self.assertIsNotNone(response.location)
expected_location = '/v1/nodes/%s/states' % self.node.name expected_location = '/v1/nodes/%s/states' % self.node.name

View File

@ -85,6 +85,36 @@ class ChangeNodePowerStateTestCase(mgr_utils.ServiceSetUpMixin,
# background task's link callback. # background task's link callback.
self.assertIsNone(node.reservation) self.assertIsNone(node.reservation)
def test_change_node_power_state_soft_power_off_timeout(self):
# Test change_node_power_state with timeout optional parameter
# including integration with conductor.utils.node_power_action and
# lower.
mgr_utils.mock_the_extension_manager(driver="fake_soft_power")
self.driver = driver_factory.get_driver("fake_soft_power")
node = obj_utils.create_test_node(self.context,
driver='fake_soft_power',
power_state=states.POWER_ON)
self._start_service()
with mock.patch.object(self.driver.power,
'get_power_state') as get_power_mock:
get_power_mock.return_value = states.POWER_ON
self.service.change_node_power_state(self.context,
node.uuid,
states.SOFT_POWER_OFF,
timeout=2)
self._stop_service()
get_power_mock.assert_called_once_with(mock.ANY)
node.refresh()
self.assertEqual(states.POWER_OFF, node.power_state)
self.assertIsNone(node.target_power_state)
self.assertIsNone(node.last_error)
# Verify the reservation has been cleared by
# background task's link callback.
self.assertIsNone(node.reservation)
@mock.patch.object(conductor_utils, 'node_power_action') @mock.patch.object(conductor_utils, 'node_power_action')
def test_change_node_power_state_node_already_locked(self, def test_change_node_power_state_node_already_locked(self,
pwr_act_mock): pwr_act_mock):
@ -135,7 +165,7 @@ class ChangeNodePowerStateTestCase(mgr_utils.ServiceSetUpMixin,
self.assertEqual(exception.NoFreeConductorWorker, exc.exc_info[0]) self.assertEqual(exception.NoFreeConductorWorker, exc.exc_info[0])
spawn_mock.assert_called_once_with(mock.ANY, mock.ANY, spawn_mock.assert_called_once_with(mock.ANY, mock.ANY,
mock.ANY) mock.ANY, timeout=mock.ANY)
node.refresh() node.refresh()
self.assertEqual(initial_state, node.power_state) self.assertEqual(initial_state, node.power_state)
self.assertIsNone(node.target_power_state) self.assertIsNone(node.target_power_state)
@ -343,7 +373,8 @@ class ChangeNodePowerStateTestCase(mgr_utils.ServiceSetUpMixin,
states.POWER_ON) states.POWER_ON)
spawn_mock.assert_called_once_with( spawn_mock.assert_called_once_with(
conductor_utils.node_power_action, mock.ANY, states.POWER_ON) conductor_utils.node_power_action, mock.ANY, states.POWER_ON,
timeout=None)
self.assertFalse(mock_notif.called) self.assertFalse(mock_notif.called)
@mock.patch('ironic.objects.node.NodeSetPowerStateNotification') @mock.patch('ironic.objects.node.NodeSetPowerStateNotification')
@ -381,6 +412,33 @@ class ChangeNodePowerStateTestCase(mgr_utils.ServiceSetUpMixin,
'baremetal.node.power_set.end', 'baremetal.node.power_set.end',
obj_fields.NotificationLevel.INFO) obj_fields.NotificationLevel.INFO)
def test_change_node_power_state_unsupported_state(self):
# Test change_node_power_state where unsupported power state raises
# an exception
initial_state = states.POWER_ON
node = obj_utils.create_test_node(self.context, driver='fake',
power_state=initial_state)
self._start_service()
with mock.patch.object(self.driver.power,
'get_supported_power_states') as supported_mock:
supported_mock.return_value = [
states.POWER_ON, states.POWER_OFF, states.REBOOT]
exc = self.assertRaises(messaging.rpc.ExpectedException,
self.service.change_node_power_state,
self.context,
node.uuid,
states.SOFT_POWER_OFF)
self.assertEqual(exception.InvalidParameterValue, exc.exc_info[0])
node.refresh()
supported_mock.assert_called_once_with(mock.ANY)
self.assertEqual(states.POWER_ON, node.power_state)
self.assertIsNone(node.target_power_state)
self.assertIsNone(node.last_error)
@mgr_utils.mock_record_keepalive @mgr_utils.mock_record_keepalive
class CreateNodeTestCase(mgr_utils.ServiceSetUpMixin, class CreateNodeTestCase(mgr_utils.ServiceSetUpMixin,

View File

@ -188,7 +188,7 @@ class RPCAPITestCase(base.DbTestCase):
def test_change_node_power_state(self): def test_change_node_power_state(self):
self._test_rpcapi('change_node_power_state', self._test_rpcapi('change_node_power_state',
'call', 'call',
version='1.6', version='1.39',
node_id=self.fake_node['uuid'], node_id=self.fake_node['uuid'],
new_state=states.POWER_ON) new_state=states.POWER_ON)

View File

@ -495,6 +495,96 @@ class NodePowerActionTestCase(base.DbTestCase):
obj_fields.NotificationLevel.ERROR) obj_fields.NotificationLevel.ERROR)
class NodeSoftPowerActionTestCase(base.DbTestCase):
def setUp(self):
super(NodeSoftPowerActionTestCase, self).setUp()
mgr_utils.mock_the_extension_manager(driver="fake_soft_power")
self.driver = driver_factory.get_driver("fake_soft_power")
def test_node_power_action_power_soft_reboot(self):
"""Test for soft reboot a node."""
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
driver='fake_soft_power',
power_state=states.POWER_ON)
task = task_manager.TaskManager(self.context, node.uuid)
with mock.patch.object(self.driver.power,
'get_power_state') as get_power_mock:
get_power_mock.return_value = states.POWER_ON
conductor_utils.node_power_action(task, states.SOFT_REBOOT)
node.refresh()
get_power_mock.assert_called_once_with(mock.ANY)
self.assertEqual(states.POWER_ON, node['power_state'])
self.assertIsNone(node['target_power_state'])
self.assertIsNone(node['last_error'])
def test_node_power_action_power_soft_reboot_timeout(self):
"""Test for soft reboot a node."""
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
driver='fake_soft_power',
power_state=states.POWER_ON)
task = task_manager.TaskManager(self.context, node.uuid)
with mock.patch.object(self.driver.power,
'get_power_state') as get_power_mock:
get_power_mock.return_value = states.POWER_ON
conductor_utils.node_power_action(task, states.SOFT_REBOOT,
timeout=2)
node.refresh()
get_power_mock.assert_called_once_with(mock.ANY)
self.assertEqual(states.POWER_ON, node['power_state'])
self.assertIsNone(node['target_power_state'])
self.assertIsNone(node['last_error'])
def test_node_power_action_soft_power_off(self):
"""Test node_power_action to turn node soft power off."""
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
driver='fake_soft_power',
power_state=states.POWER_ON)
task = task_manager.TaskManager(self.context, node.uuid)
with mock.patch.object(self.driver.power,
'get_power_state') as get_power_mock:
get_power_mock.return_value = states.POWER_ON
conductor_utils.node_power_action(task, states.SOFT_POWER_OFF)
node.refresh()
get_power_mock.assert_called_once_with(mock.ANY)
self.assertEqual(states.POWER_OFF, node['power_state'])
self.assertIsNone(node['target_power_state'])
self.assertIsNone(node['last_error'])
def test_node_power_action_soft_power_off_timeout(self):
"""Test node_power_action to turn node soft power off."""
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
driver='fake_soft_power',
power_state=states.POWER_ON)
task = task_manager.TaskManager(self.context, node.uuid)
with mock.patch.object(self.driver.power,
'get_power_state') as get_power_mock:
get_power_mock.return_value = states.POWER_ON
conductor_utils.node_power_action(task, states.SOFT_POWER_OFF,
timeout=2)
node.refresh()
get_power_mock.assert_called_once_with(mock.ANY)
self.assertEqual(states.POWER_OFF, node['power_state'])
self.assertIsNone(node['target_power_state'])
self.assertIsNone(node['last_error'])
class CleanupAfterTimeoutTestCase(tests_base.TestCase): class CleanupAfterTimeoutTestCase(tests_base.TestCase):
def setUp(self): def setUp(self):
super(CleanupAfterTimeoutTestCase, self).setUp() super(CleanupAfterTimeoutTestCase, self).setUp()

View File

@ -0,0 +1,7 @@
---
features:
- Support ``soft rebooting`` and ``soft power off`` requests to
change node's power state with API version 1.27, and also
introduce ``timeout`` optional parameter and
``[conductor]/soft_power_off_timeout`` configuration option.
Custom power drivers may be enhanced to support this feature.

View File

@ -52,6 +52,7 @@ ironic.drivers =
agent_vbox = ironic.drivers.agent:AgentAndVirtualBoxDriver agent_vbox = ironic.drivers.agent:AgentAndVirtualBoxDriver
agent_ucs = ironic.drivers.agent:AgentAndUcsDriver agent_ucs = ironic.drivers.agent:AgentAndUcsDriver
fake = ironic.drivers.fake:FakeDriver fake = ironic.drivers.fake:FakeDriver
fake_soft_power = ironic.drivers.fake:FakeSoftPowerDriver
fake_agent = ironic.drivers.fake:FakeAgentDriver fake_agent = ironic.drivers.fake:FakeAgentDriver
fake_inspector = ironic.drivers.fake:FakeIPMIToolInspectorDriver fake_inspector = ironic.drivers.fake:FakeIPMIToolInspectorDriver
fake_ipmitool = ironic.drivers.fake:FakeIPMIToolDriver fake_ipmitool = ironic.drivers.fake:FakeIPMIToolDriver