Merge "Add MANAGEABLE state and associated transitions"

This commit is contained in:
Jenkins 2015-02-05 15:11:54 +00:00 committed by Gerrit Code Review
commit 06ea4e127b
8 changed files with 197 additions and 47 deletions

View File

@ -58,7 +58,8 @@ MIN_VER = base.Version({base.Version.string: "1.1"})
# v1.2: Renamed NOSTATE ("None") to AVAILABLE ("available")
# v1.3: Add node.driver_internal_info
MAX_VER = base.Version({base.Version.string: "1.3"})
# v1.4: Add MANAGEABLE state
MAX_VER = base.Version({base.Version.string: "1.4"})
class MediaType(base.APIBase):

View File

@ -68,6 +68,14 @@ def hide_driver_internal_info(obj):
obj.driver_internal_info = wsme.Unset
def check_allow_management_verbs(verb):
# v1.4 added the MANAGEABLE state and two verbs to move nodes into
# and out of that state. Reject requests to do this in older versions
if (pecan.request.version.minor < 4 and
verb in [ir_states.VERBS['manage'], ir_states.VERBS['provide']]):
raise exception.NotAcceptable()
class NodePatchType(types.JsonPatchType):
@staticmethod
@ -313,7 +321,8 @@ class NodeStatesController(rest.RestController):
if target not in [ir_states.POWER_ON,
ir_states.POWER_OFF,
ir_states.REBOOT]:
raise exception.InvalidStateRequested(state=target, node=node_uuid)
raise exception.InvalidStateRequested(
action=target, node=node_uuid, state=rpc_node.power_state)
pecan.request.rpcapi.change_node_power_state(pecan.request.context,
node_uuid, target, topic)
@ -324,7 +333,7 @@ class NodeStatesController(rest.RestController):
@wsme_pecan.wsexpose(None, types.uuid, wtypes.text, wtypes.text,
status_code=202)
def provision(self, node_uuid, target, configdrive=None):
"""Asynchronous trigger the provisioning of the node.
"""Asynchronously trigger the provisioning of the node.
This will set the target provision state of the node, and a
background task will begin which actually applies the state
@ -338,34 +347,34 @@ class NodeStatesController(rest.RestController):
:param configdrive: Optional. A gzipped and base64 encoded
configdrive. Only valid when setting provision state
to "active".
:raises: NodeLocked (HTTP 409) if the node is currently locked.
:raises: ClientSideError (HTTP 409) if the node is already being
provisioned.
:raises: ClientSideError (HTTP 400) if the node is already in
the requested state.
:raises: InvalidStateRequested (HTTP 400) if the requested target
state is not valid.
:raises: InvalidStateRequested (HTTP 400) if the requested transition
is not possible from the current state.
:raises: NotAcceptable (HTTP 406) if the API version specified does
not allow the requested state transition.
"""
check_allow_management_verbs(target)
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
if target == rpc_node.provision_state:
msg = (_("Node %(node)s is already in the '%(state)s' state.") %
{'node': rpc_node['uuid'], 'state': target})
raise wsme.exc.ClientSideError(msg, status_code=400)
# Normally, we let the task manager recognize and deal with
# NodeLocked exceptions. However, that isn't done until the RPC calls
# below. In order to main backward compatibility with our API HTTP
# response codes, we have this check here to deal with cases where
# a node is already being operated on (DEPLOYING or such) and we
# want to continue returning 409. Without it, we'd return 400.
if rpc_node.reservation:
raise exception.NodeLocked(node=rpc_node.uuid,
host=rpc_node.reservation)
if target not in (ir_states.ACTIVE, ir_states.DELETED,
ir_states.REBUILD):
raise exception.InvalidStateRequested(state=target, node=node_uuid)
valid_states_if_processing = [ir_states.DEPLOYFAIL]
if target == ir_states.DELETED:
valid_states_if_processing.append(ir_states.DEPLOYWAIT)
if (rpc_node.target_provision_state is not None and
rpc_node.provision_state not in valid_states_if_processing):
msg = (_('Node %s is already being provisioned or decommissioned.')
% rpc_node.uuid)
raise wsme.exc.ClientSideError(msg, status_code=409) # Conflict
m = ir_states.machine.copy()
m.initialize(rpc_node.provision_state)
if not m.is_valid_event(ir_states.VERBS.get(target, target)):
raise exception.InvalidStateRequested(
action=target, node=node_uuid,
state=rpc_node.provision_state)
if configdrive and target != ir_states.ACTIVE:
msg = (_('Adding a config drive is only supported when setting '
@ -386,6 +395,15 @@ class NodeStatesController(rest.RestController):
elif target == ir_states.DELETED:
pecan.request.rpcapi.do_node_tear_down(
pecan.request.context, node_uuid, topic)
elif target in (
ir_states.VERBS['manage'], ir_states.VERBS['provide']):
pecan.request.rpcapi.do_provisioning_action(
pecan.request.context, node_uuid, target, topic)
else:
msg = (_('The requested action "%(action)s" could not be '
'understood.') % {'action': target})
raise exception.InvalidStateRequested(message=msg)
# Set the HTTP Location Header
url_args = '/'.join([node_uuid, 'states'])
pecan.response.location = link.build_url('nodes', url_args)

View File

@ -163,7 +163,8 @@ class InvalidMAC(Invalid):
class InvalidStateRequested(Invalid):
message = _("Invalid state '%(state)s' requested for node %(node)s.")
message = _('The requested action "%(action)s" can not be performed '
'on node "%(node)s" while it is in state "%(state)s".')
class PatchError(Invalid):

View File

@ -37,6 +37,25 @@ LOG = logging.getLogger(__name__)
# Provisioning states
#####################
# TODO(deva): add add'l state mappings here
VERBS = {
'active': 'deploy',
'deleted': 'delete',
'manage': 'manage',
'provide': 'provide',
}
""" Mapping of state-changing events that are PUT to the REST API
This is a mapping of target states which are PUT to the API, eg,
PUT /v1/node/states/provision {'target': 'active'}
The dict format is:
{target string used by the API: internal verb}
This provides a reference set of supported actions, and in the future
may be used to support renaming these actions.
"""
NOSTATE = None
""" No state information.
@ -44,6 +63,14 @@ This state is used with power_state to represent a lack of knowledge of
power state, and in target_*_state fields when there is no target.
"""
MANAGEABLE = 'manageable'
""" Node is in a manageable state.
This state indicates that Ironic has verified, at least once, that it had
sufficient information to manage the hardware. While in this state, the node
is not available for provisioning (it must be in the AVAILABLE state for that).
"""
AVAILABLE = 'available'
""" Node is available for use and scheduling.
@ -91,6 +118,8 @@ In Kilo, this will be a transitory value of provision_state, and never
represented in target_provision_state.
"""
# TODO(deva): add CLEAN* states
ERROR = 'error'
""" An error occurred during node processing.
@ -140,10 +169,18 @@ watchers['on_enter'] = on_enter
machine = fsm.FSM()
# Add stable states
machine.add_state(MANAGEABLE, **watchers)
machine.add_state(AVAILABLE, **watchers)
machine.add_state(ACTIVE, **watchers)
machine.add_state(ERROR, **watchers)
# From MANAGEABLE, a node may be made available
# TODO(deva): add CLEAN* states to this path
machine.add_transition(MANAGEABLE, AVAILABLE, 'provide')
# From AVAILABLE, a node may be made unavailable by managing it
machine.add_transition(AVAILABLE, MANAGEABLE, 'manage')
# Add deploy* states
# NOTE(deva): Juno shows a target_provision_state of DEPLOYDONE
# this is changed in Kilo to ACTIVE
@ -165,6 +202,8 @@ machine.add_transition(DEPLOYING, DEPLOYFAIL, 'fail')
# A failed deployment may be retried
# ironic/conductor/manager.py:do_node_deploy()
machine.add_transition(DEPLOYFAIL, DEPLOYING, 'rebuild')
# NOTE(deva): Juno allows a client to send "active" to initiate a rebuild
machine.add_transition(DEPLOYFAIL, DEPLOYING, 'deploy')
# A deployment may also wait on external callbacks
machine.add_transition(DEPLOYING, DEPLOYWAIT, 'wait')

View File

@ -172,7 +172,7 @@ class ConductorManager(periodic_task.PeriodicTasks):
"""Ironic Conductor manager main class."""
# NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's.
RPC_API_VERSION = '1.22'
RPC_API_VERSION = '1.23'
target = messaging.Target(version=RPC_API_VERSION)
@ -616,8 +616,7 @@ class ConductorManager(periodic_task.PeriodicTasks):
exception.NodeLocked,
exception.NodeInMaintenance,
exception.InstanceDeployFailure,
exception.InvalidParameterValue,
exception.MissingParameterValue)
exception.InvalidStateRequested)
def do_node_deploy(self, context, node_id, rebuild=False,
configdrive=None):
"""RPC method to initiate deployment to a node.
@ -637,6 +636,8 @@ class ConductorManager(periodic_task.PeriodicTasks):
:raises: NodeInMaintenance if the node is in maintenance mode.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
:raises: InvalidStateRequested when the requested state is not a valid
target from the current state.
"""
LOG.debug("RPC do_node_deploy called for node %s." % node_id)
@ -682,18 +683,14 @@ class ConductorManager(periodic_task.PeriodicTasks):
configdrive),
err_handler=provisioning_error_handler)
except exception.InvalidState:
raise exception.InstanceDeployFailure(_(
"Request received to %(what)s %(node)s, but "
"this is not possible in the current state of "
"'%(state)s'. ") % {'what': event,
'node': node.uuid,
'state': node.provision_state})
raise exception.InvalidStateRequested(
action=event, node=task.node.uuid,
state=task.node.provision_state)
@messaging.expected_exceptions(exception.NoFreeConductorWorker,
exception.NodeLocked,
exception.InstanceDeployFailure,
exception.InvalidParameterValue,
exception.MissingParameterValue)
exception.InvalidStateRequested)
def do_node_tear_down(self, context, node_id):
"""RPC method to tear down an existing node deployment.
@ -705,12 +702,13 @@ class ConductorManager(periodic_task.PeriodicTasks):
:raises: InstanceDeployFailure
:raises: NoFreeConductorWorker when there is no free worker to start
async task
:raises: InvalidStateRequested when the requested state is not a valid
target from the current state.
"""
LOG.debug("RPC do_node_tear_down called for node %s." % node_id)
with task_manager.acquire(context, node_id, shared=False) as task:
node = task.node
try:
# NOTE(ghe): Valid power driver values are needed to perform
# a tear-down. Deploy info is useful to purge the cache but not
@ -719,8 +717,8 @@ class ConductorManager(periodic_task.PeriodicTasks):
except (exception.InvalidParameterValue,
exception.MissingParameterValue) as e:
raise exception.InstanceDeployFailure(_(
"RPC do_node_tear_down failed to validate power info. "
"Error: %(msg)s") % {'msg': e})
"Failed to validate power driver interface. "
"Can not delete instance. Error: %(msg)s") % {'msg': e})
try:
task.process_event('delete',
@ -728,10 +726,36 @@ class ConductorManager(periodic_task.PeriodicTasks):
call_args=(do_node_tear_down, task),
err_handler=provisioning_error_handler)
except exception.InvalidState:
raise exception.InstanceDeployFailure(_(
"RPC do_node_tear_down "
"not allowed for node %(node)s in state %(state)s")
% {'node': node_id, 'state': node.provision_state})
raise exception.InvalidStateRequested(
action='delete', node=task.node.uuid,
state=task.node.provision_state)
@messaging.expected_exceptions(exception.NoFreeConductorWorker,
exception.NodeLocked,
exception.InvalidParameterValue,
exception.MissingParameterValue,
exception.InvalidStateRequested)
def do_provisioning_action(self, context, node_id, action):
"""RPC method to initiate certain provisioning state transitions.
Initiate a provisioning state change through the state machine,
rather than through an RPC call to do_node_deploy / do_node_tear_down
:param context: an admin context.
:param node_id: the id or uuid of a node.
:param action: an action. One of ironic.common.states.VERBS
:raises: InvalidParameterValue
:raises: InvalidStateRequested
:raises: NoFreeConductorWorker
"""
with task_manager.acquire(context, node_id, shared=False) as task:
try:
task.process_event(action)
except exception.InvalidState:
raise exception.InvalidStateRequested(
action=action, node=task.node.uuid,
state=task.node.provision_state)
@periodic_task.periodic_task(
spacing=CONF.conductor.sync_power_state_interval)

View File

@ -65,11 +65,12 @@ class ConductorAPI(object):
| 1.21 - Added get_node_vendor_passthru_methods and
| get_driver_vendor_passthru_methods
| 1.22 - Added configdrive parameter to do_node_deploy.
| 1.23 - Added do_provisioning_action
"""
# NOTE(rloo): This must be in sync with manager.ConductorManager's.
RPC_API_VERSION = '1.22'
RPC_API_VERSION = '1.23'
def __init__(self, topic=None):
super(ConductorAPI, self).__init__()
@ -303,6 +304,25 @@ class ConductorAPI(object):
cctxt = self.client.prepare(topic=topic or self.topic, version='1.6')
return cctxt.call(context, 'do_node_tear_down', node_id=node_id)
def do_provisioning_action(self, context, node_id, action, topic=None):
"""Signal to conductor service to perform the given action on a node.
:param context: request context.
:param node_id: node id or uuid.
:param action: an action. One of ironic.common.states.VERBS
:param topic: RPC topic. Defaults to self.topic.
:raises: InvalidParameterValue
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
:raises: InvalidStateRequested if the requested action can not
be performed.
This encapsulates some provisioning actions in a single call.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.23')
return cctxt.call(context, 'do_provisioning_action',
node_id=node_id, action=action)
def validate_driver_interfaces(self, context, node_id, topic=None):
"""Validate the `core` and `standardized` interfaces for drivers.

View File

@ -1257,6 +1257,53 @@ class TestPut(test_api_base.FunctionalTest):
expect_errors=True)
self.assertEqual(400, ret.status_code)
def test_manage_raises_error_before_1_2(self):
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
{'target': states.VERBS['manage']},
headers={},
expect_errors=True)
self.assertEqual(406, ret.status_code)
@mock.patch.object(rpcapi.ConductorAPI, 'do_provisioning_action')
def test_provide_from_manage(self, mock_dpa):
self.node.provision_state = states.MANAGEABLE
self.node.save()
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
{'target': states.VERBS['provide']},
headers={api_base.Version.string: "1.4"})
self.assertEqual(202, ret.status_code)
self.assertEqual('', ret.body)
mock_dpa.assert_called_once_with(mock.ANY, self.node.uuid,
states.VERBS['provide'],
'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'do_provisioning_action')
def test_manage_from_available(self, mock_dpa):
self.node.provision_state = states.AVAILABLE
self.node.save()
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
{'target': states.VERBS['manage']},
headers={api_base.Version.string: "1.4"})
self.assertEqual(202, ret.status_code)
self.assertEqual('', ret.body)
mock_dpa.assert_called_once_with(mock.ANY, self.node.uuid,
states.VERBS['manage'],
'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'do_provisioning_action')
def test_bad_requests_in_managed_state(self, mock_dpa):
self.node.provision_state = states.MANAGEABLE
self.node.save()
for state in [states.ACTIVE, states.REBUILD, states.DELETED]:
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
{'target': states.ACTIVE},
expect_errors=True)
self.assertEqual(400, ret.status_code)
self.assertEqual(0, mock_dpa.call_count)
def test_set_console_mode_enabled(self):
with mock.patch.object(rpcapi.ConductorAPI,
'set_console_mode') as mock_scm:

View File

@ -925,7 +925,7 @@ class DoNodeDeployTearDownTestCase(_ServiceSetUpMixin,
self.service.do_node_deploy,
self.context, node['uuid'])
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.InstanceDeployFailure, exc.exc_info[0])
self.assertEqual(exception.InvalidStateRequested, exc.exc_info[0])
# This is a sync operation last_error should be None.
self.assertIsNone(node.last_error)
# Verify reservation has been cleared.
@ -1246,7 +1246,7 @@ class DoNodeDeployTearDownTestCase(_ServiceSetUpMixin,
self.service.do_node_deploy,
self.context, node['uuid'], rebuild=True)
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.InstanceDeployFailure, exc.exc_info[0])
self.assertEqual(exception.InvalidStateRequested, exc.exc_info[0])
# Last_error should be None.
self.assertIsNone(node.last_error)
# Verify reservation has been cleared.
@ -1304,7 +1304,7 @@ class DoNodeDeployTearDownTestCase(_ServiceSetUpMixin,
self.service.do_node_tear_down,
self.context, node['uuid'])
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.InstanceDeployFailure, exc.exc_info[0])
self.assertEqual(exception.InvalidStateRequested, exc.exc_info[0])
@mock.patch('ironic.drivers.modules.fake.FakePower.validate')
def test_do_node_tear_down_validate_fail(self, mock_validate):