Add base state machine

This creates the ironic.common.fsm module, which was imported from
TaskFlow and modified to fit the use-case of Ironic, and adds a model of
our current states to the ironic.common.states module.

Notable changes from the fsm.py module in TaskFlow are:
- removing the table printing dependency
- basing exceptions on Ironic's exception class names (which, it turns
  out, are almost identical)
- add is_valid_event() method
- allowing the state to be passed when initializing a FSM
- allowing shallow copies of state machines
- adding target-state-tracking to fsm.py

Related-To: blueprint new-ironic-state-machine
Co-Authored-By: David Shrewsbury <shrewsbury.dave@gmail.com>
Co-Authored-By: Chris Krelle <nobodycam@gmail.com>

Change-Id: I813e60563c3e5124e31183b9322152df830b2f8e
This commit is contained in:
Devananda van der Veen 2014-12-02 17:17:14 -08:00
parent 5aa4f315b3
commit b49470e161
4 changed files with 438 additions and 1 deletions

View File

@ -186,6 +186,10 @@ class MissingParameterValue(InvalidParameterValue):
message = _("%(err)s")
class Duplicate(IronicException):
message = _("Resource already exists.")
class NotFound(IronicException):
message = _("Resource could not be found.")
code = 404

219
ironic/common/fsm.py Normal file
View File

@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014 Yahoo! Inc. 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.
"""State machine modelling, copied from TaskFlow project.
This work will be turned into a library.
See https://github.com/harlowja/automaton
"""
from collections import OrderedDict # noqa
import six
from ironic.common import exception as excp
from ironic.common.i18n import _
class _Jump(object):
"""A FSM transition tracks this data while jumping."""
def __init__(self, name, on_enter, on_exit):
self.name = name
self.on_enter = on_enter
self.on_exit = on_exit
class FSM(object):
"""A finite state machine.
This class models a state machine, and expects an outside caller to
manually trigger the state changes one at a time by invoking process_event
"""
def __init__(self, start_state=None):
self._transitions = {}
self._states = OrderedDict()
self._start_state = start_state
self._target_state = None
# Note that _current is a _Jump instance
self._current = None
@property
def start_state(self):
return self._start_state
@property
def current_state(self):
if self._current is not None:
return self._current.name
return None
@property
def target_state(self):
return self._target_state
@property
def terminated(self):
"""Returns whether the state machine is in a terminal state."""
if self._current is None:
return False
return self._states[self._current.name]['terminal']
def add_state(self, state, on_enter=None, on_exit=None,
target=None, terminal=None):
"""Adds a given state to the state machine.
The on_enter and on_exit callbacks, if provided will be expected to
take two positional parameters, these being the state being exited (for
on_exit) or the state being entered (for on_enter) and a second
parameter which is the event that is being processed that caused the
state transition.
"""
if state in self._states:
raise excp.Duplicate(_("State '%s' already defined") % state)
if on_enter is not None:
if not six.callable(on_enter):
raise ValueError(_("On enter callback must be callable"))
if on_exit is not None:
if not six.callable(on_exit):
raise ValueError(_("On exit callback must be callable"))
if target is not None and target not in self._states:
raise excp.InvalidState(_("Target state '%s' does not exist")
% target)
self._states[state] = {
'terminal': bool(terminal),
'reactions': {},
'on_enter': on_enter,
'on_exit': on_exit,
'target': target,
}
self._transitions[state] = OrderedDict()
def add_transition(self, start, end, event):
"""Adds an allowed transition from start -> end for the given event."""
if start not in self._states:
raise excp.NotFound(
_("Can not add a transition on event '%(event)s' that "
"starts in a undefined state '%(state)s'")
% {'event': event, 'state': start})
if end not in self._states:
raise excp.NotFound(
_("Can not add a transition on event '%(event)s' that "
"ends in a undefined state '%(state)s'")
% {'event': event, 'state': end})
self._transitions[start][event] = _Jump(end,
self._states[end]['on_enter'],
self._states[start]['on_exit'])
def process_event(self, event):
"""Trigger a state change in response to the provided event."""
current = self._current
if current is None:
raise excp.InvalidState(_("Can only process events after"
" being initialized (not before)"))
if self._states[current.name]['terminal']:
raise excp.InvalidState(
_("Can not transition from terminal "
"state '%(state)s' on event '%(event)s'")
% {'state': current.name, 'event': event})
if event not in self._transitions[current.name]:
raise excp.InvalidState(
_("Can not transition from state '%(state)s' on "
"event '%(event)s' (no defined transition)")
% {'state': current.name, 'event': event})
replacement = self._transitions[current.name][event]
if current.on_exit is not None:
current.on_exit(current.name, event)
if replacement.on_enter is not None:
replacement.on_enter(replacement.name, event)
self._current = replacement
# clear _target if we've reached it
if (self._target_state is not None and
self._target_state == replacement.name):
self._target_state = None
# set target if there is a new one
if (self._target_state is None and
self._states[replacement.name]['target'] is not None):
self._target_state = self._states[replacement.name]['target']
def is_valid_event(self, event):
"""Check whether the event is actionable in the current state."""
current = self._current
if current is None:
return False
if self._states[current.name]['terminal']:
return False
if event not in self._transitions[current.name]:
return False
return True
def initialize(self, state=None):
"""Sets up the state machine.
sets the current state to the specified state, or start_state
if no state was specified..
"""
if state is None:
state = self._start_state
if state not in self._states:
raise excp.NotFound(_("Can not start from an undefined"
" state '%s'") % (state))
if self._states[state]['terminal']:
raise excp.InvalidState(_("Can not start from a terminal"
" state '%s'") % (state))
self._current = _Jump(state, None, None)
def copy(self, shallow=False):
"""Copies the current state machine (shallow or deep).
NOTE(harlowja): the copy will be left in an *uninitialized* state.
NOTE(harlowja): when a shallow copy is requested the copy will share
the same transition table and state table as the
source; this can be advantageous if you have a machine
and transitions + states that is defined somewhere
and want to use copies to run with (the copies have
the current state that is different between machines).
"""
c = FSM(self.start_state)
if not shallow:
for state, data in six.iteritems(self._states):
copied_data = data.copy()
copied_data['reactions'] = copied_data['reactions'].copy()
c._states[state] = copied_data
for state, data in six.iteritems(self._transitions):
c._transitions[state] = data.copy()
else:
c._transitions = self._transitions
c._states = self._states
return c
def __contains__(self, state):
"""Returns if this state exists in the machines known states."""
return state in self._states
@property
def states(self):
"""Returns a list of the state names."""
return list(six.iterkeys(self._states))
@property
def events(self):
"""Returns how many events exist."""
c = 0
for state in six.iterkeys(self._states):
c += len(self._transitions[state])
return c

View File

@ -28,6 +28,10 @@ the state leaves the current state unchanged. The node is NOT placed into
maintenance mode in this case.
"""
from ironic.common import fsm
from ironic.openstack.common import log as logging
LOG = logging.getLogger(__name__)
#####################
# Provisioning states
@ -85,7 +89,11 @@ The `last_error` attribute of the node details should contain an error message.
"""
REBUILD = 'rebuild'
""" Node is currently being rebuilt. """
""" Node is to be rebuilt.
This is not used as a state, but rather as a "verb" when changing the node's
provision_state via the REST API.
"""
##############
@ -100,3 +108,92 @@ POWER_OFF = 'power off'
REBOOT = 'rebooting'
""" Node is rebooting. """
#####################
# State machine model
#####################
def on_exit(old_state, event):
"""Used to log when a state is exited."""
LOG.debug("Exiting old state '%s' in response to event '%s'",
old_state, event)
def on_enter(new_state, event):
"""Used to log when entering a state."""
LOG.debug("Entering new state '%s' in response to event '%s'",
new_state, event)
watchers = {}
watchers['on_exit'] = on_exit
watchers['on_enter'] = on_enter
machine = fsm.FSM()
# Add stable states
machine.add_state(NOSTATE, **watchers)
machine.add_state(ACTIVE, **watchers)
machine.add_state(ERROR, **watchers)
# Add deploy* states
machine.add_state(DEPLOYDONE, target=ACTIVE, **watchers)
machine.add_state(DEPLOYING, target=DEPLOYDONE, **watchers)
machine.add_state(DEPLOYWAIT, **watchers)
machine.add_state(DEPLOYFAIL, **watchers)
# Add delete* states
machine.add_state(DELETED, target=NOSTATE, **watchers)
machine.add_state(DELETING, target=DELETED, **watchers)
# From NOSTATE, a deployment may be started
machine.add_transition(NOSTATE, DEPLOYING, 'deploy')
# A deployment may fail
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')
# A deployment may also wait on external callbacks
machine.add_transition(DEPLOYING, DEPLOYWAIT, 'wait')
machine.add_transition(DEPLOYWAIT, DEPLOYING, 'resume')
# A deployment waiting on callback may time out
machine.add_transition(DEPLOYWAIT, DEPLOYFAIL, 'fail')
# A deployment may complete
machine.add_transition(DEPLOYING, ACTIVE, 'done')
# An active instance may be re-deployed
# ironic/conductor/manager.py:do_node_deploy()
machine.add_transition(ACTIVE, DEPLOYING, 'rebuild')
# An active instance may be deleted
# ironic/conductor/manager.py:do_node_tear_down()
machine.add_transition(ACTIVE, DELETING, 'delete')
# While a deployment is waiting, it may be deleted
# ironic/conductor/manager.py:do_node_tear_down()
machine.add_transition(DEPLOYWAIT, DELETING, 'delete')
# A failed deployment may also be deleted
# ironic/conductor/manager.py:do_node_tear_down()
machine.add_transition(DEPLOYFAIL, DELETING, 'delete')
# A delete may complete
machine.add_transition(DELETING, NOSTATE, 'done')
# These states can also transition to error
machine.add_transition(NOSTATE, ERROR, 'error')
machine.add_transition(DEPLOYING, ERROR, 'error')
machine.add_transition(ACTIVE, ERROR, 'error')
machine.add_transition(DELETING, ERROR, 'error')
# An errored instance can be rebuilt
# ironic/conductor/manager.py:do_node_deploy()
machine.add_transition(ERROR, DEPLOYING, 'rebuild')
# or deleted
# ironic/conductor/manager.py:do_node_tear_down()
machine.add_transition(ERROR, DELETING, 'delete')

117
ironic/tests/test_fsm.py Normal file
View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014 Yahoo! Inc. 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.
from ironic.common import exception as excp
from ironic.common import fsm
from ironic.tests import base
class FSMTest(base.TestCase):
def setUp(self):
super(FSMTest, self).setUp()
self.jumper = fsm.FSM("down")
self.jumper.add_state('up')
self.jumper.add_state('down')
self.jumper.add_transition('down', 'up', 'jump')
self.jumper.add_transition('up', 'down', 'fall')
def test_contains(self):
m = fsm.FSM('unknown')
self.assertNotIn('unknown', m)
m.add_state('unknown')
self.assertIn('unknown', m)
def test_duplicate_state(self):
m = fsm.FSM('unknown')
m.add_state('unknown')
self.assertRaises(excp.Duplicate, m.add_state, 'unknown')
def test_bad_transition(self):
m = fsm.FSM('unknown')
m.add_state('unknown')
m.add_state('fire')
self.assertRaises(excp.NotFound, m.add_transition,
'unknown', 'something', 'boom')
self.assertRaises(excp.NotFound, m.add_transition,
'something', 'unknown', 'boom')
def test_on_enter_on_exit(self):
def on_exit(state, event):
exit_transitions.append((state, event))
def on_enter(state, event):
enter_transitions.append((state, event))
enter_transitions = []
exit_transitions = []
m = fsm.FSM('start')
m.add_state('start', on_exit=on_exit)
m.add_state('down', on_enter=on_enter, on_exit=on_exit)
m.add_state('up', on_enter=on_enter, on_exit=on_exit)
m.add_transition('start', 'down', 'beat')
m.add_transition('down', 'up', 'jump')
m.add_transition('up', 'down', 'fall')
m.initialize()
m.process_event('beat')
m.process_event('jump')
m.process_event('fall')
self.assertEqual([('down', 'beat'),
('up', 'jump'), ('down', 'fall')], enter_transitions)
self.assertEqual([('down', 'jump'), ('up', 'fall')], exit_transitions)
def test_not_initialized(self):
self.assertRaises(excp.InvalidState,
self.jumper.process_event, 'jump')
def test_copy_states(self):
c = fsm.FSM()
self.assertEqual(0, len(c.states))
c.add_state('up')
self.assertEqual(1, len(c.states))
deep = c.copy()
shallow = c.copy(shallow=True)
c.add_state('down')
c.add_transition('up', 'down', 'fall')
self.assertEqual(2, len(c.states))
# deep copy created new members, so change is not visible
self.assertEqual(1, len(deep.states))
self.assertNotEqual(c._transitions, deep._transitions)
# but a shallow copy references the same state object
self.assertEqual(2, len(shallow.states))
self.assertEqual(c._transitions, shallow._transitions)
def test_copy_clears_current(self):
c = fsm.FSM()
c.add_state('up')
c.initialize('up')
d = c.copy()
self.assertEqual('up', c.current_state)
self.assertEqual(None, d.current_state)
def test_invalid_callbacks(self):
m = fsm.FSM('working')
m.add_state('working')
m.add_state('broken')
self.assertRaises(ValueError, m.add_state, 'b', on_enter=2)
self.assertRaises(ValueError, m.add_state, 'b', on_exit=2)