diff options
| author | Jenkins <jenkins@review.openstack.org> | 2016-08-22 23:03:07 +0000 |
|---|---|---|
| committer | Gerrit Code Review <review@openstack.org> | 2016-08-22 23:03:07 +0000 |
| commit | 5e04d15a5a10587d15dab211689532bb4d45b77b (patch) | |
| tree | 8aeb54cc824bad13dab9a169be44b74a483128f8 | |
| parent | 68014293b076c78bfe4b030f954bdccb960e2823 (diff) | |
| parent | e7c286ff1dc38f0d881c0048a35c944a95efa7db (diff) | |
| download | python-ironicclient-5e04d15a5a10587d15dab211689532bb4d45b77b.tar.gz | |
Merge "Add --wait flag for provision actions and wait_for_provision_state function"
| -rw-r--r-- | ironicclient/exc.py | 8 | ||||
| -rw-r--r-- | ironicclient/tests/unit/v1/test_node.py | 115 | ||||
| -rw-r--r-- | ironicclient/tests/unit/v1/test_node_shell.py | 48 | ||||
| -rw-r--r-- | ironicclient/v1/node.py | 80 | ||||
| -rw-r--r-- | ironicclient/v1/node_shell.py | 61 | ||||
| -rw-r--r-- | releasenotes/notes/provision-state-wait-e7ff919ce8e13703.yaml | 5 |
6 files changed, 312 insertions, 5 deletions
diff --git a/ironicclient/exc.py b/ironicclient/exc.py index be3d753..85caf01 100644 --- a/ironicclient/exc.py +++ b/ironicclient/exc.py @@ -36,6 +36,14 @@ class InvalidAttribute(ClientException): pass +class StateTransitionFailed(ClientException): + """Failed to reach a requested provision state.""" + + +class StateTransitionTimeout(ClientException): + """Timed out while waiting for a requested provision state.""" + + def from_response(response, message=None, traceback=None, method=None, url=None): """Return an HttpError instance based on response from httplib/requests.""" diff --git a/ironicclient/tests/unit/v1/test_node.py b/ironicclient/tests/unit/v1/test_node.py index 5acb386..ff2b181 100644 --- a/ironicclient/tests/unit/v1/test_node.py +++ b/ironicclient/tests/unit/v1/test_node.py @@ -14,6 +14,7 @@ import copy import tempfile +import time import mock import six @@ -1049,3 +1050,117 @@ class NodeManagerTest(testtools.TestCase): ] self.assertEqual(expect, self.api.calls) self.assertEqual(NODE_VENDOR_PASSTHRU_METHOD, vendor_methods) + + def _fake_node_for_wait(self, state, error=None, target=None): + spec = ['provision_state', 'last_error', 'target_provision_state'] + return mock.Mock(provision_state=state, + last_error=error, + target_provision_state=target, + spec=spec) + + @mock.patch.object(time, 'sleep', autospec=True) + @mock.patch.object(node.NodeManager, 'get', autospec=True) + def test_wait_for_provision_state(self, mock_get, mock_sleep): + mock_get.side_effect = [ + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('active') + ] + + self.mgr.wait_for_provision_state('node', 'active') + + mock_get.assert_called_with(self.mgr, 'node') + self.assertEqual(3, mock_get.call_count) + mock_sleep.assert_called_with(node._DEFAULT_POLL_INTERVAL) + self.assertEqual(2, mock_sleep.call_count) + + @mock.patch.object(time, 'sleep', autospec=True) + @mock.patch.object(node.NodeManager, 'get', autospec=True) + def test_wait_for_provision_state_timeout(self, mock_get, mock_sleep): + mock_get.return_value = self._fake_node_for_wait( + 'deploying', target='active') + + self.assertRaises(exc.StateTransitionTimeout, + self.mgr.wait_for_provision_state, 'node', 'active', + timeout=0.001) + + @mock.patch.object(time, 'sleep', autospec=True) + @mock.patch.object(node.NodeManager, 'get', autospec=True) + def test_wait_for_provision_state_error(self, mock_get, mock_sleep): + mock_get.side_effect = [ + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('deploy failed', error='boom'), + ] + + self.assertRaisesRegexp(exc.StateTransitionFailed, + 'boom', + self.mgr.wait_for_provision_state, + 'node', 'active') + + mock_get.assert_called_with(self.mgr, 'node') + self.assertEqual(2, mock_get.call_count) + mock_sleep.assert_called_with(node._DEFAULT_POLL_INTERVAL) + self.assertEqual(1, mock_sleep.call_count) + + @mock.patch.object(node.NodeManager, 'get', autospec=True) + def test_wait_for_provision_state_custom_delay(self, mock_get): + mock_get.side_effect = [ + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('active') + ] + + delay_mock = mock.Mock() + self.mgr.wait_for_provision_state('node', 'active', + poll_delay_function=delay_mock) + + mock_get.assert_called_with(self.mgr, 'node') + self.assertEqual(2, mock_get.call_count) + delay_mock.assert_called_with(node._DEFAULT_POLL_INTERVAL) + self.assertEqual(1, delay_mock.call_count) + + def test_wait_for_provision_state_wrong_input(self): + self.assertRaises(ValueError, self.mgr.wait_for_provision_state, + 'node', 'active', timeout='42') + self.assertRaises(ValueError, self.mgr.wait_for_provision_state, + 'node', 'active', timeout=-1) + self.assertRaises(TypeError, self.mgr.wait_for_provision_state, + 'node', 'active', poll_delay_function={}) + + @mock.patch.object(time, 'sleep', autospec=True) + @mock.patch.object(node.NodeManager, 'get', autospec=True) + def test_wait_for_provision_state_unexpected_stable_state( + self, mock_get, mock_sleep): + # This simulates aborted deployment + mock_get.side_effect = [ + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('available'), + ] + + self.assertRaisesRegexp(exc.StateTransitionFailed, + 'available', + self.mgr.wait_for_provision_state, + 'node', 'active') + + mock_get.assert_called_with(self.mgr, 'node') + self.assertEqual(2, mock_get.call_count) + mock_sleep.assert_called_with(node._DEFAULT_POLL_INTERVAL) + self.assertEqual(1, mock_sleep.call_count) + + @mock.patch.object(time, 'sleep', autospec=True) + @mock.patch.object(node.NodeManager, 'get', autospec=True) + def test_wait_for_provision_state_unexpected_stable_state_allowed( + self, mock_get, mock_sleep): + mock_get.side_effect = [ + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('available'), + self._fake_node_for_wait('deploying', target='active'), + self._fake_node_for_wait('active'), + ] + + self.mgr.wait_for_provision_state('node', 'active', + fail_on_unexpected_state=False) + + mock_get.assert_called_with(self.mgr, 'node') + self.assertEqual(4, mock_get.call_count) + mock_sleep.assert_called_with(node._DEFAULT_POLL_INTERVAL) + self.assertEqual(3, mock_sleep.call_count) diff --git a/ironicclient/tests/unit/v1/test_node_shell.py b/ironicclient/tests/unit/v1/test_node_shell.py index 1e71754..590a976 100644 --- a/ironicclient/tests/unit/v1/test_node_shell.py +++ b/ironicclient/tests/unit/v1/test_node_shell.py @@ -486,10 +486,28 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'active' args.config_drive = 'foo' args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( 'node_uuid', 'active', configdrive='foo', cleansteps=None) + self.assertFalse(client_mock.node.wait_for_provision_state.called) + + def test_do_node_set_provision_state_active_wait(self): + client_mock = mock.MagicMock() + args = mock.MagicMock() + args.node = 'node_uuid' + args.provision_state = 'active' + args.config_drive = 'foo' + args.clean_steps = None + args.wait_timeout = 0 + + n_shell.do_node_set_provision_state(client_mock, args) + client_mock.node.set_provision_state.assert_called_once_with( + 'node_uuid', 'active', configdrive='foo', cleansteps=None) + client_mock.node.wait_for_provision_state.assert_called_once_with( + 'node_uuid', expected_state='active', timeout=0, + poll_interval=n_shell._LONG_ACTION_POLL_INTERVAL) def test_do_node_set_provision_state_deleted(self): client_mock = mock.MagicMock() @@ -498,6 +516,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'deleted' args.config_drive = None args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -510,6 +529,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'rebuild' args.config_drive = None args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -522,6 +542,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'deleted' args.config_drive = 'foo' args.clean_steps = None + args.wait_timeout = None self.assertRaises(exceptions.CommandError, n_shell.do_node_set_provision_state, @@ -535,6 +556,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'inspect' args.config_drive = None args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -547,6 +569,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'manage' args.config_drive = None args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -559,6 +582,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'provide' args.config_drive = None args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -572,6 +596,7 @@ class NodeShellTest(utils.BaseTestCase): args.config_drive = None clean_steps = '[{"step": "upgrade", "interface": "deploy"}]' args.clean_steps = clean_steps + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -588,6 +613,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'clean' args.config_drive = None args.clean_steps = '-' + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) mock_stdin.assert_called_once_with('clean steps') @@ -604,6 +630,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'clean' args.config_drive = None args.clean_steps = '-' + args.wait_timeout = None self.assertRaises(exc.InvalidAttribute, n_shell.do_node_set_provision_state, @@ -624,6 +651,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'clean' args.config_drive = None args.clean_steps = f.name + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -637,6 +665,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'clean' args.config_drive = None args.clean_steps = None + args.wait_timeout = None # clean_steps isn't specified self.assertRaisesRegex(exceptions.CommandError, @@ -653,6 +682,7 @@ class NodeShellTest(utils.BaseTestCase): args.config_drive = None clean_steps = '[{"step": "upgrade", "interface": "deploy"}]' args.clean_steps = clean_steps + args.wait_timeout = None # clean_steps specified but not cleaning self.assertRaisesRegex(exceptions.CommandError, @@ -668,6 +698,7 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'abort' args.config_drive = None args.clean_steps = None + args.wait_timeout = None n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( @@ -680,11 +711,28 @@ class NodeShellTest(utils.BaseTestCase): args.provision_state = 'adopt' args.config_drive = None args.clean_steps = None + args.wait_timeout = 0 n_shell.do_node_set_provision_state(client_mock, args) client_mock.node.set_provision_state.assert_called_once_with( 'node_uuid', 'adopt', cleansteps=None, configdrive=None) + def test_do_node_set_provision_state_abort_no_wait(self): + client_mock = mock.MagicMock() + args = mock.MagicMock() + args.node = 'node_uuid' + args.provision_state = 'abort' + args.config_drive = None + args.clean_steps = None + args.wait_timeout = 0 + + self.assertRaisesRegex(exceptions.CommandError, + "not supported for provision state 'abort'", + n_shell.do_node_set_provision_state, + client_mock, args) + self.assertFalse(client_mock.node.set_provision_state.called) + self.assertFalse(client_mock.node.wait_for_provision_state.called) + def test_do_node_set_console_mode(self): client_mock = mock.MagicMock() args = mock.MagicMock() diff --git a/ironicclient/v1/node.py b/ironicclient/v1/node.py index b161c36..b64202b 100644 --- a/ironicclient/v1/node.py +++ b/ironicclient/v1/node.py @@ -12,7 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. +import logging import os +import time from oslo_utils import strutils @@ -29,6 +31,10 @@ _power_states = { } +LOG = logging.getLogger(__name__) +_DEFAULT_POLL_INTERVAL = 2 + + class Node(base.Resource): def __repr__(self): return "<Node %s>" % self._info @@ -355,3 +361,77 @@ class NodeManager(base.CreateManager): def get_vendor_passthru_methods(self, node_ident): path = "%s/vendor_passthru/methods" % node_ident return self.get(path).to_dict() + + def wait_for_provision_state(self, node_ident, expected_state, + timeout=0, + poll_interval=_DEFAULT_POLL_INTERVAL, + poll_delay_function=None, + fail_on_unexpected_state=True): + """Helper function to wait for a node to reach a given state. + + Polls Ironic API in a loop until node gets to a requested state. + + Fails in the following cases: + * Timeout (if provided) is reached + * Node's last_error gets set to a non-empty value + * Unexpected stable state is reached and fail_on_unexpected_state is on + * Error state is reached (if it's not equal to expected_state) + + :param node_ident: node UUID or name + :param expected_state: expected final provision state + :param timeout: timeout in seconds, no timeout if 0 + :param poll_interval: interval in seconds between 2 poll + :param poll_delay_function: function to use to wait between polls + (defaults to time.sleep). Should take one argument - delay time + in seconds. Any exceptions raised inside it will abort the wait. + :param fail_on_unexpected_state: whether to fail if the nodes + reaches a different stable state. + :raises: StateTransitionFailed if node reached an error state + :raises: StateTransitionTimeout on timeout + """ + if not isinstance(timeout, (int, float)) or timeout < 0: + raise ValueError(_('Timeout must be a non-negative number')) + + threshold = time.time() + timeout + expected_state = expected_state.lower() + poll_delay_function = (time.sleep if poll_delay_function is None + else poll_delay_function) + if not callable(poll_delay_function): + raise TypeError(_('poll_delay_function must be callable')) + + # TODO(dtantsur): use version negotiation to request API 1.8 and use + # the "fields" argument to reduce amount of data sent. + while not timeout or time.time() < threshold: + node = self.get(node_ident) + if node.provision_state == expected_state: + LOG.debug('Node %(node)s reached provision state %(state)s', + {'node': node_ident, 'state': expected_state}) + return + + # Note that if expected_state == 'error' we still succeed + if (node.last_error or node.provision_state == 'error' or + node.provision_state.endswith(' failed')): + raise exc.StateTransitionFailed( + _('Node %(node)s failed to reach state %(state)s. ' + 'It\'s in state %(actual)s, and has error: %(error)s') % + {'node': node_ident, 'state': expected_state, + 'actual': node.provision_state, 'error': node.last_error}) + + if fail_on_unexpected_state and not node.target_provision_state: + raise exc.StateTransitionFailed( + _('Node %(node)s failed to reach state %(state)s. ' + 'It\'s in unexpected stable state %(actual)s') % + {'node': node_ident, 'state': expected_state, + 'actual': node.provision_state}) + + LOG.debug('Still waiting for node %(node)s to reach state ' + '%(state)s, the current state is %(actual)s', + {'node': node_ident, 'state': expected_state, + 'actual': node.provision_state}) + poll_delay_function(poll_interval) + + raise exc.StateTransitionTimeout( + _('Node %(node)s failed to reach state %(state)s in ' + '%(timeout)s seconds') % {'node': node_ident, + 'state': expected_state, + 'timeout': timeout}) diff --git a/ironicclient/v1/node_shell.py b/ironicclient/v1/node_shell.py index c3222e2..d8ebce2 100644 --- a/ironicclient/v1/node_shell.py +++ b/ironicclient/v1/node_shell.py @@ -23,6 +23,35 @@ from ironicclient import exc from ironicclient.v1 import resource_fields as res_fields +# Polling intervals in seconds. +_LONG_ACTION_POLL_INTERVAL = 10 +_SHORT_ACTION_POLL_INTERVAL = 2 +# This dict acts as both list of possible provision actions and arguments for +# wait_for_provision_state invocation. +PROVISION_ACTIONS = { + 'active': {'expected_state': 'active', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'deleted': {'expected_state': 'available', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'rebuild': {'expected_state': 'active', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'inspect': {'expected_state': 'manageable', + # This is suboptimal for in-band inspection, but it's probably + # not worth making people wait 10 seconds for OOB inspection + 'poll_interval': _SHORT_ACTION_POLL_INTERVAL}, + 'provide': {'expected_state': 'available', + # This assumes cleaning is in place + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'manage': {'expected_state': 'manageable', + 'poll_interval': _SHORT_ACTION_POLL_INTERVAL}, + 'clean': {'expected_state': 'manageable', + 'poll_interval': _LONG_ACTION_POLL_INTERVAL}, + 'adopt': {'expected_state': 'active', + 'poll_interval': _SHORT_ACTION_POLL_INTERVAL}, + 'abort': None, # no support for --wait in abort +} + + def _print_node_show(node, fields=None, json=False): if fields is None: fields = res_fields.NODE_DETAILED_RESOURCE.fields @@ -445,11 +474,9 @@ def do_node_set_target_raid_config(cc, args): @cliutils.arg( 'provision_state', metavar='<provision-state>', - choices=['active', 'deleted', 'rebuild', 'inspect', 'provide', - 'manage', 'clean', 'abort', 'adopt'], - help="Supported states: 'active', 'deleted', 'rebuild', " - "'inspect', 'provide', 'manage', 'clean', 'abort', " - "or 'adopt'.") + choices=list(PROVISION_ACTIONS), + help="Supported states: %s." % ', '.join("'%s'" % state + for state in PROVISION_ACTIONS)) @cliutils.arg( '--config-drive', metavar='<config-drive>', @@ -470,6 +497,18 @@ def do_node_set_target_raid_config(cc, args): "keys 'interface' and 'step', and optional key 'args'. " "This argument must be specified (and is only valid) when " "setting provision-state to 'clean'.")) +@cliutils.arg( + '--wait', + type=int, + dest='wait_timeout', + default=None, + const=0, + nargs='?', + help=("Wait for a node to reach the expected state. Not supported " + "for 'abort'. Optionally takes a timeout in seconds. " + "The default value is 0, meaning no timeout. " + "Fails if the node reaches an unexpected stable state, a failure " + "state or a state with last_error set.")) def do_node_set_provision_state(cc, args): """Initiate a provisioning state change for a node.""" if args.config_drive and args.provision_state != 'active': @@ -482,6 +521,13 @@ def do_node_set_provision_state(cc, args): raise exceptions.CommandError(_('--clean-steps must be specified when ' 'setting provision state to "clean"')) + if args.wait_timeout is not None: + wait_args = PROVISION_ACTIONS.get(args.provision_state) + if wait_args is None: + raise exceptions.CommandError( + _("--wait is not supported for provision state '%s'") + % args.provision_state) + clean_steps = args.clean_steps if args.clean_steps == '-': clean_steps = utils.get_from_stdin('clean steps') @@ -490,6 +536,11 @@ def do_node_set_provision_state(cc, args): cc.node.set_provision_state(args.node, args.provision_state, configdrive=args.config_drive, cleansteps=clean_steps) + if args.wait_timeout is not None: + print(_('Waiting for provision state %(state)s on node %(node)s') % + {'state': wait_args['expected_state'], 'node': args.node}) + cc.node.wait_for_provision_state(args.node, timeout=args.wait_timeout, + **wait_args) @cliutils.arg('node', metavar='<node>', help="Name or UUID of the node.") diff --git a/releasenotes/notes/provision-state-wait-e7ff919ce8e13703.yaml b/releasenotes/notes/provision-state-wait-e7ff919ce8e13703.yaml new file mode 100644 index 0000000..0b699a2 --- /dev/null +++ b/releasenotes/notes/provision-state-wait-e7ff919ce8e13703.yaml @@ -0,0 +1,5 @@ +--- +features: + - Add --wait flag to "node-set-provision-state" command and the associated + "node.wait_for_provision_state" method to the Python API. + The flag works with all provision actions except for "abort". |
