Merge "Extend conditions and actions"
This commit is contained in:
commit
03b690d48c
@ -74,6 +74,16 @@ A condition is represented by an object with fields:
|
||||
``field`` a `JSON path <http://goessner.net/articles/JsonPath/>`_ to the field
|
||||
in the introspection data to use in comparison.
|
||||
|
||||
Starting with the Mitaka release, you can also apply conditions to ironic node
|
||||
field. Prefix field with schema (``data://`` or ``node://``) to distinguish
|
||||
between values from introspection data and node. Both schemes use JSON path::
|
||||
|
||||
{'field': 'node://property.path', 'op': 'eq', 'value': 'val'}
|
||||
{'field': 'data://introspection.path', 'op': 'eq', 'value': 'val'}
|
||||
|
||||
if scheme (node or data) is missing, condition compares data with
|
||||
introspection data.
|
||||
|
||||
``multiple`` how to treat situations where the ``field`` query returns multiple
|
||||
results (e.g. the field contains a list), available options are:
|
||||
|
||||
@ -110,6 +120,13 @@ Default available actions include:
|
||||
value as a list and appends value to it. If optional ``unique`` parameter is
|
||||
set to ``True``, nothing will be added if given value is already in a list.
|
||||
|
||||
Starting from Mitaka release, ``value`` field in actions supports fetching data
|
||||
from introspection, it's using `python string formatting notation
|
||||
<https://docs.python.org/2/library/string.html#formatspec>`_ ::
|
||||
|
||||
{'action': 'set-attribute', 'path': '/driver_info/ipmi_address',
|
||||
'value': '{data[inventory][bmc_address]}'}
|
||||
|
||||
.. _setting-ipmi-creds:
|
||||
|
||||
Setting IPMI Credentials
|
||||
|
@ -116,6 +116,9 @@ class RuleConditionPlugin(WithValidation): # pragma: no cover
|
||||
class RuleActionPlugin(WithValidation): # pragma: no cover
|
||||
"""Abstract base class for rule action plugins."""
|
||||
|
||||
FORMATTED_PARAMS = []
|
||||
"""List of params will be formatted with python format."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def apply(self, node_info, params, **kwargs):
|
||||
"""Run action on successful rule match.
|
||||
|
@ -113,6 +113,8 @@ class SetAttributeAction(base.RuleActionPlugin):
|
||||
REQUIRED_PARAMS = {'path', 'value'}
|
||||
# TODO(dtantsur): proper validation of path
|
||||
|
||||
FORMATTED_PARAMS = ['value']
|
||||
|
||||
def apply(self, node_info, params, **kwargs):
|
||||
node_info.patch([{'op': 'add', 'path': params['path'],
|
||||
'value': params['value']}])
|
||||
@ -122,6 +124,8 @@ class SetCapabilityAction(base.RuleActionPlugin):
|
||||
REQUIRED_PARAMS = {'name'}
|
||||
OPTIONAL_PARAMS = {'value'}
|
||||
|
||||
FORMATTED_PARAMS = ['value']
|
||||
|
||||
def apply(self, node_info, params, **kwargs):
|
||||
node_info.update_capabilities(
|
||||
**{params['name']: params.get('value')})
|
||||
@ -132,6 +136,8 @@ class ExtendAttributeAction(base.RuleActionPlugin):
|
||||
OPTIONAL_PARAMS = {'unique'}
|
||||
# TODO(dtantsur): proper validation of path
|
||||
|
||||
FORMATTED_PARAMS = ['value']
|
||||
|
||||
def apply(self, node_info, params, **kwargs):
|
||||
def _replace(values):
|
||||
value = params['value']
|
||||
|
@ -131,7 +131,14 @@ class IntrospectionRule(object):
|
||||
node_info=node_info, data=data)
|
||||
ext_mgr = plugins_base.rule_conditions_manager()
|
||||
for cond in self._conditions:
|
||||
field_values = jsonpath.parse(cond.field).find(data)
|
||||
scheme, path = _parse_path(cond.field)
|
||||
|
||||
if scheme == 'node':
|
||||
source_data = node_info.node().dict()
|
||||
elif scheme == 'data':
|
||||
source_data = data
|
||||
|
||||
field_values = jsonpath.parse(path).find(source_data)
|
||||
field_values = [x.value for x in field_values]
|
||||
cond_ext = ext_mgr[cond.op].obj
|
||||
|
||||
@ -172,7 +179,7 @@ class IntrospectionRule(object):
|
||||
|
||||
:param node_info: NodeInfo instance
|
||||
:param rollback: if True, rollback actions are executed
|
||||
:param data: introspection data (only used for logging)
|
||||
:param data: introspection data
|
||||
"""
|
||||
if rollback:
|
||||
method = 'rollback'
|
||||
@ -185,11 +192,26 @@ class IntrospectionRule(object):
|
||||
|
||||
ext_mgr = plugins_base.rule_actions_manager()
|
||||
for act in self._actions:
|
||||
ext = ext_mgr[act.action].obj
|
||||
for formatted_param in ext.FORMATTED_PARAMS:
|
||||
value = act.params.get(formatted_param)
|
||||
if not value:
|
||||
continue
|
||||
|
||||
# NOTE(aarefiev): verify provided value with introspection
|
||||
# data format specifications.
|
||||
# TODO(aarefiev): simple verify on import rule time.
|
||||
try:
|
||||
act.params[formatted_param] = value.format(data=data)
|
||||
except KeyError as e:
|
||||
raise utils.Error(_('Invalid formatting variable key '
|
||||
'provided: %s') % e,
|
||||
node_info=node_info, data=data)
|
||||
|
||||
LOG.debug('Running %(what)s action `%(action)s %(params)s`',
|
||||
{'action': act.action, 'params': act.params,
|
||||
'what': method},
|
||||
node_info=node_info, data=data)
|
||||
ext = ext_mgr[act.action].obj
|
||||
getattr(ext, method)(node_info, act.params)
|
||||
|
||||
LOG.debug('Successfully applied %s',
|
||||
@ -197,6 +219,27 @@ class IntrospectionRule(object):
|
||||
node_info=node_info, data=data)
|
||||
|
||||
|
||||
def _parse_path(path):
|
||||
"""Parse path, extract scheme and path.
|
||||
|
||||
Parse path with 'node' and 'data' scheme, which links on
|
||||
introspection data and node info respectively. If scheme is
|
||||
missing in path, default is 'data'.
|
||||
|
||||
:param path: data or node path
|
||||
:return: tuple (scheme, path)
|
||||
"""
|
||||
try:
|
||||
index = path.index('://')
|
||||
except ValueError:
|
||||
scheme = 'data'
|
||||
path = path
|
||||
else:
|
||||
scheme = path[:index]
|
||||
path = path[index + 3:]
|
||||
return scheme, path
|
||||
|
||||
|
||||
def create(conditions_json, actions_json, uuid=None,
|
||||
description=None):
|
||||
"""Create a new rule in database.
|
||||
@ -235,8 +278,15 @@ def create(conditions_json, actions_json, uuid=None,
|
||||
conditions = []
|
||||
for cond_json in conditions_json:
|
||||
field = cond_json['field']
|
||||
|
||||
scheme, path = _parse_path(field)
|
||||
|
||||
if scheme not in ('node', 'data'):
|
||||
raise utils.Error(_('Unsupported scheme for field: %s, valid '
|
||||
'values are node:// or data://') % scheme)
|
||||
# verify field as JSON path
|
||||
try:
|
||||
jsonpath.parse(field)
|
||||
jsonpath.parse(path)
|
||||
except Exception as exc:
|
||||
raise utils.Error(_('Unable to parse field JSON path %(field)s: '
|
||||
'%(error)s') % {'field': field, 'error': exc})
|
||||
@ -364,7 +414,7 @@ def apply(node_info, data):
|
||||
if to_rollback:
|
||||
LOG.debug('Running rollback actions', node_info=node_info, data=data)
|
||||
for rule in to_rollback:
|
||||
rule.apply_actions(node_info, rollback=True)
|
||||
rule.apply_actions(node_info, rollback=True, data=data)
|
||||
else:
|
||||
LOG.debug('No rollback actions to apply',
|
||||
node_info=node_info, data=data)
|
||||
@ -372,7 +422,7 @@ def apply(node_info, data):
|
||||
if to_apply:
|
||||
LOG.debug('Running actions', node_info=node_info, data=data)
|
||||
for rule in to_apply:
|
||||
rule.apply_actions(node_info, rollback=False)
|
||||
rule.apply_actions(node_info, rollback=False, data=data)
|
||||
else:
|
||||
LOG.debug('No actions to apply', node_info=node_info, data=data)
|
||||
|
||||
|
@ -88,15 +88,23 @@ class NodeTest(BaseTest):
|
||||
self.uuid = uuidutils.generate_uuid()
|
||||
self.bmc_address = '1.2.3.4'
|
||||
self.macs = ['11:22:33:44:55:66', '66:55:44:33:22:11']
|
||||
self.node = mock.Mock(driver='pxe_ipmitool',
|
||||
driver_info={'ipmi_address': self.bmc_address},
|
||||
properties={'cpu_arch': 'i386', 'local_gb': 40},
|
||||
uuid=self.uuid,
|
||||
power_state='power on',
|
||||
provision_state='inspecting',
|
||||
extra={},
|
||||
instance_uuid=None,
|
||||
maintenance=False)
|
||||
fake_node = {
|
||||
'driver': 'pxe_ipmitool',
|
||||
'driver_info': {'ipmi_address': self.bmc_address},
|
||||
'properties': {'cpu_arch': 'i386', 'local_gb': 40},
|
||||
'uuid': self.uuid,
|
||||
'power_state': 'power on',
|
||||
'provision_state': 'inspecting',
|
||||
'extra': {},
|
||||
'instance_uuid': None,
|
||||
'maintenance': False
|
||||
}
|
||||
mock_to_dict = mock.Mock(return_value=fake_node)
|
||||
|
||||
self.node = mock.Mock(**fake_node)
|
||||
self.node.dict = mock_to_dict
|
||||
|
||||
self.ports = []
|
||||
self.node_info = node_cache.NodeInfo(uuid=self.uuid, started_at=0,
|
||||
node=self.node, ports=self.ports)
|
||||
self.node_info.node = mock.Mock(return_value=self.node)
|
||||
|
@ -337,6 +337,49 @@ class Test(Base):
|
||||
self.uuid,
|
||||
[{'op': 'add', 'path': '/extra/foo', 'value': 'bar'}])
|
||||
|
||||
def test_conditions_scheme_actions_path(self):
|
||||
rules = [
|
||||
{
|
||||
'conditions': [
|
||||
{'field': 'node://properties.local_gb', 'op': 'eq',
|
||||
'value': 40},
|
||||
{'field': 'node://driver_info.ipmi_address', 'op': 'eq',
|
||||
'value': self.bmc_address},
|
||||
],
|
||||
'actions': [
|
||||
{'action': 'set-attribute', 'path': '/extra/foo',
|
||||
'value': 'bar'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'conditions': [
|
||||
{'field': 'data://inventory.cpu.count', 'op': 'eq',
|
||||
'value': self.data['inventory']['cpu']['count']},
|
||||
],
|
||||
'actions': [
|
||||
{'action': 'set-attribute',
|
||||
'path': '/driver_info/ipmi_address',
|
||||
'value': '{data[inventory][bmc_address]}'}
|
||||
]
|
||||
}
|
||||
]
|
||||
for rule in rules:
|
||||
self.call_add_rule(rule)
|
||||
|
||||
self.call_introspect(self.uuid)
|
||||
eventlet.greenthread.sleep(DEFAULT_SLEEP)
|
||||
self.call_continue(self.data)
|
||||
eventlet.greenthread.sleep(DEFAULT_SLEEP)
|
||||
|
||||
self.cli.node.update.assert_any_call(
|
||||
self.uuid,
|
||||
[{'op': 'add', 'path': '/extra/foo', 'value': 'bar'}])
|
||||
|
||||
self.cli.node.update.assert_any_call(
|
||||
self.uuid,
|
||||
[{'op': 'add', 'path': '/driver_info/ipmi_address',
|
||||
'value': self.data['inventory']['bmc_address']}])
|
||||
|
||||
def test_root_device_hints(self):
|
||||
self.node.properties['root_device'] = {'size': 20}
|
||||
|
||||
|
@ -18,14 +18,13 @@ import mock
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from ironic_inspector import db
|
||||
from ironic_inspector import node_cache
|
||||
from ironic_inspector.plugins import base as plugins_base
|
||||
from ironic_inspector import rules
|
||||
from ironic_inspector.test import base as test_base
|
||||
from ironic_inspector import utils
|
||||
|
||||
|
||||
class BaseTest(test_base.BaseTest):
|
||||
class BaseTest(test_base.NodeTest):
|
||||
def setUp(self):
|
||||
super(BaseTest, self).setUp()
|
||||
self.uuid = uuidutils.generate_uuid()
|
||||
@ -41,7 +40,6 @@ class BaseTest(test_base.BaseTest):
|
||||
'memory_mb': 1024,
|
||||
'local_gb': 42,
|
||||
}
|
||||
self.node_info = node_cache.NodeInfo(uuid=self.uuid, started_at=42)
|
||||
|
||||
|
||||
class TestCreateRule(BaseTest):
|
||||
@ -321,6 +319,38 @@ class TestCheckConditionsMultiple(BaseTest):
|
||||
data)
|
||||
|
||||
|
||||
class TestCheckConditionsSchemePath(BaseTest):
|
||||
def test_conditions_data_path(self):
|
||||
self.data_set = [
|
||||
([{'op': 'eq', 'field': 'data://memory_mb', 'value': 1024}],
|
||||
True),
|
||||
([{'op': 'gt', 'field': 'data://local_gb', 'value': 42}],
|
||||
False)
|
||||
]
|
||||
|
||||
for condition, res in self.data_set:
|
||||
rule = rules.create(conditions_json=condition,
|
||||
actions_json=self.actions_json)
|
||||
self.assertIs(res,
|
||||
rule.check_conditions(self.node_info, self.data),
|
||||
self.data)
|
||||
|
||||
def test_conditions_node_path(self):
|
||||
self.node_set = [
|
||||
([{'op': 'eq', 'field': 'node://driver_info.ipmi_address',
|
||||
'value': self.bmc_address}],
|
||||
True),
|
||||
([{'op': 'eq', 'field': 'node://driver', 'value': 'fake'}],
|
||||
False)
|
||||
]
|
||||
|
||||
for condition, res in self.node_set:
|
||||
rule = rules.create(conditions_json=condition,
|
||||
actions_json=self.actions_json)
|
||||
self.assertIs(res,
|
||||
rule.check_conditions(self.node_info, self.data))
|
||||
|
||||
|
||||
@mock.patch.object(plugins_base, 'rule_actions_manager', autospec=True)
|
||||
class TestApplyActions(BaseTest):
|
||||
def setUp(self):
|
||||
@ -330,12 +360,13 @@ class TestApplyActions(BaseTest):
|
||||
self.rule = rules.create(conditions_json=self.conditions_json,
|
||||
actions_json=self.actions_json)
|
||||
self.act_mock = mock.Mock(spec=plugins_base.RuleActionPlugin)
|
||||
self.act_mock.FORMATTED_PARAMS = ['value']
|
||||
self.ext_mock = mock.Mock(spec=['obj'], obj=self.act_mock)
|
||||
|
||||
def test_apply(self, mock_ext_mgr):
|
||||
mock_ext_mgr.return_value.__getitem__.return_value = self.ext_mock
|
||||
|
||||
self.rule.apply_actions(self.node_info)
|
||||
self.rule.apply_actions(self.node_info, data=self.data)
|
||||
|
||||
self.act_mock.apply.assert_any_call(self.node_info,
|
||||
{'message': 'boom!'})
|
||||
@ -344,6 +375,33 @@ class TestApplyActions(BaseTest):
|
||||
self.act_mock.apply.call_count)
|
||||
self.assertFalse(self.act_mock.rollback.called)
|
||||
|
||||
def test_apply_data_format_value(self, mock_ext_mgr):
|
||||
self.rule = rules.create(actions_json=[
|
||||
{'action': 'set-attribute',
|
||||
'path': '/driver_info/ipmi_address',
|
||||
'value': '{data[memory_mb]}'}],
|
||||
conditions_json=self.conditions_json
|
||||
)
|
||||
mock_ext_mgr.return_value.__getitem__.return_value = self.ext_mock
|
||||
|
||||
self.rule.apply_actions(self.node_info, data=self.data)
|
||||
|
||||
self.assertEqual(1, self.act_mock.apply.call_count)
|
||||
self.assertFalse(self.act_mock.rollback.called)
|
||||
|
||||
def test_apply_data_format_value_fail(self, mock_ext_mgr):
|
||||
self.rule = rules.create(
|
||||
actions_json=[
|
||||
{'action': 'set-attribute',
|
||||
'path': '/driver_info/ipmi_address',
|
||||
'value': '{data[inventory][bmc_address]}'}],
|
||||
conditions_json=self.conditions_json
|
||||
)
|
||||
mock_ext_mgr.return_value.__getitem__.return_value = self.ext_mock
|
||||
|
||||
self.assertRaises(utils.Error, self.rule.apply_actions,
|
||||
self.node_info, data=self.data)
|
||||
|
||||
def test_rollback(self, mock_ext_mgr):
|
||||
mock_ext_mgr.return_value.__getitem__.return_value = self.ext_mock
|
||||
|
||||
@ -380,7 +438,7 @@ class TestApply(BaseTest):
|
||||
rule.check_conditions.assert_called_once_with(self.node_info,
|
||||
self.data)
|
||||
rule.apply_actions.assert_called_once_with(
|
||||
self.node_info, rollback=bool(idx))
|
||||
self.node_info, rollback=bool(idx), data=self.data)
|
||||
|
||||
def test_actions(self, mock_get_all):
|
||||
mock_get_all.return_value = self.rules
|
||||
@ -393,7 +451,7 @@ class TestApply(BaseTest):
|
||||
rule.check_conditions.assert_called_once_with(self.node_info,
|
||||
self.data)
|
||||
rule.apply_actions.assert_called_once_with(
|
||||
self.node_info, rollback=bool(idx))
|
||||
self.node_info, rollback=bool(idx), data=self.data)
|
||||
|
||||
def test_no_rollback(self, mock_get_all):
|
||||
mock_get_all.return_value = self.rules
|
||||
@ -406,7 +464,7 @@ class TestApply(BaseTest):
|
||||
rule.check_conditions.assert_called_once_with(self.node_info,
|
||||
self.data)
|
||||
rule.apply_actions.assert_called_once_with(
|
||||
self.node_info, rollback=False)
|
||||
self.node_info, rollback=False, data=self.data)
|
||||
|
||||
def test_only_rollback(self, mock_get_all):
|
||||
mock_get_all.return_value = self.rules
|
||||
@ -419,4 +477,4 @@ class TestApply(BaseTest):
|
||||
rule.check_conditions.assert_called_once_with(self.node_info,
|
||||
self.data)
|
||||
rule.apply_actions.assert_called_once_with(
|
||||
self.node_info, rollback=True)
|
||||
self.node_info, rollback=True, data=self.data)
|
||||
|
5
releasenotes/notes/extend-rules-9a9d38701e970611.yaml
Normal file
5
releasenotes/notes/extend-rules-9a9d38701e970611.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
features:
|
||||
- Conditions now support comparing fields from node info;
|
||||
- Actions support formatting to fetch values from introspection data.
|
||||
See http://docs.openstack.org/developer/ironic-inspector/usage.html#introspection-rules
|
Loading…
Reference in New Issue
Block a user