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:
parent
5aa4f315b3
commit
b49470e161
@ -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
219
ironic/common/fsm.py
Normal 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
|
@ -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
117
ironic/tests/test_fsm.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user