From 461e83071d0ec854c8a001c9a72e9cb1064d7a33 Mon Sep 17 00:00:00 2001 From: Toshiya Kobayashi Date: Mon, 11 Nov 2024 17:58:49 +0900 Subject: [PATCH] =?UTF-8?q?[DROOLS-7639]=20ansible-rulebook=20:=20support?= =?UTF-8?q?=20event=20path=20for=20collecting=20el=E2=80=A6=20(#124)=20(#1?= =?UTF-8?q?28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DROOLS-7639] ansible-rulebook : support event path for collecting elements in array in array - WIP: test case only * WIP implementation * - improved nested list management * Added more tests * convert Set too --- .../rulebook/integration/api/LogUtil.java | 27 +- .../api/ArrayAccessWithoutIndexTest.java | 775 ++++++++++++++++++ .../protoextractor/ExtractorUtils.java | 4 +- .../prototype/ValueCollectVisitor.java | 161 ++++ .../prototype/ValueExtractionVisitor.java | 4 + 5 files changed, 959 insertions(+), 12 deletions(-) create mode 100644 drools-ansible-rulebook-integration-api/src/test/java/org/drools/ansible/rulebook/integration/api/ArrayAccessWithoutIndexTest.java create mode 100644 drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueCollectVisitor.java diff --git a/drools-ansible-rulebook-integration-api/src/main/java/org/drools/ansible/rulebook/integration/api/LogUtil.java b/drools-ansible-rulebook-integration-api/src/main/java/org/drools/ansible/rulebook/integration/api/LogUtil.java index b83f13b5..d1732b91 100644 --- a/drools-ansible-rulebook-integration-api/src/main/java/org/drools/ansible/rulebook/integration/api/LogUtil.java +++ b/drools-ansible-rulebook-integration-api/src/main/java/org/drools/ansible/rulebook/integration/api/LogUtil.java @@ -1,6 +1,8 @@ package org.drools.ansible.rulebook.integration.api; +import java.util.List; import java.util.Map; +import java.util.Set; public class LogUtil { @@ -10,18 +12,23 @@ private LogUtil() { // convert java class to python class private static Map, String> convertMap = Map.of( - java.lang.Integer.class, "int", - java.lang.Boolean.class, "bool", - java.lang.String.class, "str", - java.lang.Double.class, "float", - java.util.List.class, "list", - java.util.ArrayList.class, "list", - java.util.Map.class, "dict", - java.util.LinkedHashMap.class, "dict", - java.util.HashMap.class, "dict" + Integer.class, "int", + Boolean.class, "bool", + String.class, "str", + Double.class, "float" ); public static String convertJavaClassToPythonClass(Class javaClass) { - return convertMap.getOrDefault(javaClass, javaClass.getSimpleName()); + if (convertMap.containsKey(javaClass)) { + return convertMap.get(javaClass); + } + if (List.class.isAssignableFrom(javaClass)) { + return "list"; + } else if (Map.class.isAssignableFrom(javaClass)) { + return "dict"; + } else if (Set.class.isAssignableFrom(javaClass)) { + return "set"; + } + return javaClass.getSimpleName(); } } diff --git a/drools-ansible-rulebook-integration-api/src/test/java/org/drools/ansible/rulebook/integration/api/ArrayAccessWithoutIndexTest.java b/drools-ansible-rulebook-integration-api/src/test/java/org/drools/ansible/rulebook/integration/api/ArrayAccessWithoutIndexTest.java new file mode 100644 index 00000000..a54dd920 --- /dev/null +++ b/drools-ansible-rulebook-integration-api/src/test/java/org/drools/ansible/rulebook/integration/api/ArrayAccessWithoutIndexTest.java @@ -0,0 +1,775 @@ +package org.drools.ansible.rulebook.integration.api; + +import java.util.List; + +import org.junit.Ignore; +import org.junit.Test; +import org.kie.api.runtime.rule.Match; + +import static org.junit.Assert.assertEquals; + +public class ArrayAccessWithoutIndexTest { + + @Test + public void testSelectAttrForArrayInArray() { + + String JSON_ARRAY_IN_ARRAY = + """ + { + "rules":[ + { + "Rule":{ + "name":"r1", + "condition":{ + "AllCondition":[ + { + "SelectAttrExpression":{ + "lhs":{ + "Event":"incident.alerts.tags" + }, + "rhs":{ + "key":{ + "String":"value" + }, + "operator":{ + "String":"==" + }, + "value":{ + "String":"DiskUsage" + } + } + } + } + ] + }, + "actions":[ + { + "Action":{ + "action":"debug", + "action_args":{ + "msg":"Found a match with alerts" + } + } + } + ], + "enabled":true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "incident":{ + "id":"aaa", + "active":false, + "alerts":[ + { + "id":"bbb", + "tags":[ + { + "name":"alertname", + "value":"MariadbDown" + }, + { + "name":"severity", + "value":"critical" + } + ], + "status":"Ok" + }, + { + "id":"ccc", + "tags":[ + { + "name":"severity", + "value":"critical" + }, + { + "name":"alertname", + "value":"DiskUsage" + } + ], + "status":"Ok" + } + ] + } + } + """ ).join(); + assertEquals( 1, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Test + public void testSelectAttrForArrayInMapInArray() { + + String JSON_ARRAY_IN_MAP_IN_ARRAY = + """ + { + "rules":[ + { + "Rule":{ + "name":"r1", + "condition":{ + "AllCondition":[ + { + "SelectAttrExpression":{ + "lhs":{ + "Event":"incident.alerts.meta.tags" + }, + "rhs":{ + "key":{ + "String":"value" + }, + "operator":{ + "String":"==" + }, + "value":{ + "String":"DiskUsage" + } + } + } + } + ] + }, + "actions":[ + { + "Action":{ + "action":"debug", + "action_args":{ + "msg":"Found a match with alerts" + } + } + } + ], + "enabled":true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_MAP_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "incident":{ + "id":"aaa", + "active":false, + "alerts":[ + { + "id":"bbb", + "meta": { + "tags":[ + { + "name":"alertname", + "value":"MariadbDown" + }, + { + "name":"severity", + "value":"critical" + } + ], + "status":"Ok" + } + }, + { + "id":"ccc", + "meta": { + "tags":[ + { + "name":"severity", + "value":"critical" + }, + { + "name":"alertname", + "value":"DiskUsage" + } + ], + "status":"Ok" + } + } + ] + } + } + """ ).join(); + assertEquals( 1, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Test + public void testSelectAttrForArrayInArrayWithIndex() { + + String JSON_ARRAY_IN_ARRAY_WITH_INDEX = + """ + { + "rules":[ + { + "Rule":{ + "name":"r1", + "condition":{ + "AllCondition":[ + { + "SelectAttrExpression":{ + "lhs":{ + "Event":"incident.alerts.tags[1]" + }, + "rhs":{ + "key":{ + "String":"value" + }, + "operator":{ + "String":"==" + }, + "value":{ + "String":"DiskUsage" + } + } + } + } + ] + }, + "actions":[ + { + "Action":{ + "action":"debug", + "action_args":{ + "msg":"Found a match with alerts" + } + } + } + ], + "enabled":true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY_WITH_INDEX); + + List matchedRules = rulesExecutor.processFacts( """ + { + "incident":{ + "id":"aaa", + "active":false, + "alerts":[ + { + "id":"bbb", + "tags":[ + { + "name":"alertname", + "value":"MariadbDown" + }, + { + "name":"severity", + "value":"critical" + } + ], + "status":"Ok" + }, + { + "id":"ccc", + "tags":[ + { + "name":"severity", + "value":"critical" + }, + { + "name":"alertname", + "value":"DiskUsage" + } + ], + "status":"Ok" + } + ] + } + } + """ ).join(); + assertEquals( 1, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Test + public void testSelectAttrForArrayInArrayInArray() { + + String JSON_ARRAY_IN_ARRAY_IN_ARRAY = + """ + { + "rules":[ + { + "Rule":{ + "name":"r1", + "condition":{ + "AllCondition":[ + { + "SelectAttrExpression":{ + "lhs":{ + "Event":"incident.alerts.tags.messages" + }, + "rhs":{ + "key":{ + "String":"value" + }, + "operator":{ + "String":"==" + }, + "value":{ + "String":"DiskUsage" + } + } + } + } + ] + }, + "actions":[ + { + "Action":{ + "action":"debug", + "action_args":{ + "msg":"Found a match with alerts" + } + } + } + ], + "enabled":true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "incident":{ + "id":"aaa", + "active":false, + "alerts":[ + { + "id":"bbb", + "tags":[ + { + "messages":[ + { + "name":"alertname", + "value":"MariadbDown" + }, + { + "name":"severity", + "value":"critical" + } + ] + }, + { + "messages":[ + { + "name":"severity", + "value":"low" + }, + { + "name":"notification", + "value":"access" + } + ] + } + ], + "status":"Ok" + }, + { + "id":"ccc", + "tags":[ + { + "messages":[ + { + "name":"severity", + "value":"critical" + }, + { + "name":"alertname", + "value":"DiskUsage" + } + ] + }, + { + "messages":[ + { + "name":"severity", + "value":"low" + }, + { + "name":"notification", + "value":"access" + } + ] + } + ], + "status":"Ok" + } + ] + } + } + """ ).join(); + assertEquals( 1, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Ignore("At the moment, non-index-array ('alerts') does not support two-dimensional arrays.") + @Test + public void testSelectAttrForTwoDimensionArray() { + + String JSON_TWO_DIMENSION_ARRAY = + """ + { + "rules":[ + { + "Rule":{ + "name":"r1", + "condition":{ + "AllCondition":[ + { + "SelectAttrExpression":{ + "lhs":{ + "Event":"incident.alerts.tags" + }, + "rhs":{ + "key":{ + "String":"value" + }, + "operator":{ + "String":"==" + }, + "value":{ + "String":"DiskUsage" + } + } + } + } + ] + }, + "actions":[ + { + "Action":{ + "action":"debug", + "action_args":{ + "msg":"Found a match with alerts" + } + } + } + ], + "enabled":true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_TWO_DIMENSION_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "incident":{ + "id":"aaa", + "active":false, + "alerts":[ + [ + { + "name":"alertname", + "value":"MariadbDown" + }, + { + "name":"severity", + "value":"critical" + } + ], + [ + { + "name":"severity", + "value":"critical" + }, + { + "name":"alertname", + "value":"DiskUsage" + } + ] + ] + } + } + """ ).join(); + + assertEquals( 1, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Test + public void testListContainsForArrayInArray() { + + String JSON_ARRAY_IN_ARRAY = + """ + { + "rules":[ + { + "Rule":{ + "name":"r1", + "condition":{ + "AllCondition":[ + { + "ListContainsItemExpression":{ + "lhs":{ + "Event":"incident.alerts.tags" + }, + "rhs":{ + "String":"DiskUsage" + } + } + } + ] + }, + "actions":[ + { + "Action":{ + "action":"debug", + "action_args":{ + "msg":"Found a match with alerts" + } + } + } + ], + "enabled":true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "incident":{ + "id":"aaa", + "active":false, + "alerts":[ + { + "id":"bbb", + "tags":[ + "MariadbDown", + "Hello" + ], + "status":"Ok" + }, + { + "id":"ccc", + "tags":[ + "Good Bye", + "DiskUsage" + ], + "status":"Ok" + } + ] + } + } + """ ).join(); + assertEquals( 1, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Test + public void testSelectForArrayInArray() { + + String JSON_ARRAY_IN_ARRAY = + """ + { + "rules": [ + { + "Rule": { + "name": "r1", + "condition": { + "AllCondition": [ + { + "SelectExpression": { + "lhs": { + "Event": "persons.levels" + }, + "rhs": { + "operator": { + "String": ">" + }, + "value": { + "Integer": 25 + } + } + } + } + ] + }, + "actions": [ + { + "Action": { + "action": "echo", + "action_args": { + "message": "Hurray" + } + } + } + ], + "enabled": true + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts(""" + { + "persons":[ + { + "name":"Fred", + "age":54, + "levels":[ + 10, + 20, + 30 + ] + }, + { + "name":"John", + "age":36, + "levels":[ + 10, + 16 + ] + } + ] + } + """).join(); + assertEquals(1, matchedRules.size()); + + rulesExecutor.dispose(); + } + + @Test + public void testSimpleOperatorWithArrayCollectAsLeafNode_shouldFail() { + String JSON_ARRAY_IN_ARRAY = + """ + { + "rules": [ + { + "Rule": { + "condition": { + "AllCondition": [ + { + "EqualsExpression": { + "lhs": { + "Fact": "os.array[1].versions" + }, + "rhs": { + "String": "Vista" + } + } + } + ] + }, + "enabled": true, + "name": null + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "host":"B", + "os":{ + "array":[ + { + "name":"abc", + "versions":"Unknown" + }, + { + "name":"windows", + "versions":["XP", "Millenium", "Vista"] + } + ] + } + } + """ ).join(); + + // "os.array[1].versions" returns a list, so it hits a type mismatch error "list and str" + assertEquals( 0, matchedRules.size() ); + + rulesExecutor.dispose(); + } + + @Test + public void testSimpleOperatorWithArrayCollectAsIntermediateNode_shouldFail() { + String JSON_ARRAY_IN_ARRAY = + """ + { + "rules": [ + { + "Rule": { + "condition": { + "AllCondition": [ + { + "EqualsExpression": { + "lhs": { + "Fact": "os.array.versions[2]" + }, + "rhs": { + "String": "Vista" + } + } + } + ] + }, + "enabled": true, + "name": null + } + } + ] + } + """; + + RulesExecutor rulesExecutor = RulesExecutorFactory.createFromJson(JSON_ARRAY_IN_ARRAY); + + List matchedRules = rulesExecutor.processFacts( """ + { + "host":"B", + "os":{ + "array":[ + { + "name":"abc", + "versions":"Unknown" + }, + { + "name":"windows", + "versions":["XP", "Millenium", "Vista"] + } + ] + } + } + """ ).join(); + + // "os.array.versions[2]" returns a list, because 'array' means all elements in 'array'. + // So it hits a type mismatch error "list and str" + assertEquals( 0, matchedRules.size() ); + + rulesExecutor.dispose(); + } +} diff --git a/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/ExtractorUtils.java b/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/ExtractorUtils.java index e2d0c508..fbbd5595 100644 --- a/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/ExtractorUtils.java +++ b/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/ExtractorUtils.java @@ -4,7 +4,7 @@ import org.drools.ansible.rulebook.integration.protoextractor.ast.ExtractorNode; import org.drools.ansible.rulebook.integration.protoextractor.prototype.NormalizedFieldRepresentationVisitor; -import org.drools.ansible.rulebook.integration.protoextractor.prototype.ValueExtractionVisitor; +import org.drools.ansible.rulebook.integration.protoextractor.prototype.ValueCollectVisitor; public class ExtractorUtils { private ExtractorUtils() { @@ -16,6 +16,6 @@ public static List getParts(ExtractorNode extractorNode) { } public static Object getValueFrom(ExtractorNode extractorNode, Object readValue) { - return new ValueExtractionVisitor(readValue).visit(extractorNode); + return new ValueCollectVisitor(readValue).visit(extractorNode); } } diff --git a/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueCollectVisitor.java b/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueCollectVisitor.java new file mode 100644 index 00000000..bfddd4cd --- /dev/null +++ b/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueCollectVisitor.java @@ -0,0 +1,161 @@ +package org.drools.ansible.rulebook.integration.protoextractor.prototype; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.drools.ansible.rulebook.integration.protoextractor.ast.ASTNode; +import org.drools.ansible.rulebook.integration.protoextractor.ast.DefaultedVisitor; +import org.drools.ansible.rulebook.integration.protoextractor.ast.ExtractorNode; +import org.drools.ansible.rulebook.integration.protoextractor.ast.IdentifierNode; +import org.drools.ansible.rulebook.integration.protoextractor.ast.IndexAccessorNode; +import org.drools.ansible.rulebook.integration.protoextractor.ast.SquaredAccessorNode; +import org.kie.api.prototype.Prototype; + +/** + * ValueCollectVisitor is an improved version of ValueExtractionVisitor. + * This visitor can handle a path expression of an array without an index (e.g. 'alerts' for 'alerts[]'). + * In the case, this visitor evaluates all elements in the array and collects matching children. + * If there is no such a path expression, this works the same as ValueExtractionVisitor. + */ +public class ValueCollectVisitor extends DefaultedVisitor { + private final Object original; + private Object cur; // cur doesn't have to one Node in the event, it can be List of Nodes matching the path + + public ValueCollectVisitor(Object original) { + this.original = original; + this.cur = this.original; + } + + @Override + public Object defaultVisit(ASTNode n) { + throw new UnsupportedOperationException("this visitor implemented all visit methods"); + } + + @Override + public Object visit(IdentifierNode n) { + if (cur instanceof List) { + cur = collectPathsForAllElementsInArray(n); + } else { + cur = fromMap(cur, n.getValue()); + } + return cur; + } + + private Object collectPathsForAllElementsInArray(IdentifierNode n) { + // if accessing a list without an index, evaluate all elements and collect matching children + List theList = (List) cur; + List nextNodeList = new PathWrapperList(); + for (Object element : theList) { + Object nextNode = fromMap(element, n.getValue()); + if (nextNode != Prototype.UNDEFINED_VALUE) { + nextNodeList.add(nextNode); + } + } + if (nextNodeList.isEmpty()) { + return Prototype.UNDEFINED_VALUE; + } else { + return nextNodeList; + } + } + + private static Object fromMap(Object in, String key) { + if (in instanceof Map) { + Map theMap = (Map) in; + if (theMap.containsKey(key)) { + return theMap.get(key); + } else { + return Prototype.UNDEFINED_VALUE; + } + } else { + return Prototype.UNDEFINED_VALUE; + } + } + + @Override + public Object visit(SquaredAccessorNode n) { + cur = fromMap(cur, n.getValue()); + return cur; + } + + @Override + public Object visit(IndexAccessorNode n) { + if (cur instanceof List) { + List theList = (List) cur; + int javaIdx = n.getValue() >= 0 ? n.getValue() : theList.size() + n.getValue(); + if (javaIdx < theList.size()) { // avoid index out of bounds + cur = theList.get(javaIdx); + } else { + cur = Prototype.UNDEFINED_VALUE; + } + } else { + cur = Prototype.UNDEFINED_VALUE; + } + return cur; + } + + @Override + public Object visit(ExtractorNode n) { + for (ASTNode chunk : n.getValues()) { + if (this.cur == null || this.cur == Prototype.UNDEFINED_VALUE) { + break; + } + if (this.cur instanceof PathWrapperList currentNodeList) { + PathWrapperList nextNodeList = new PathWrapperList(); + // Apply extraction to all paths in the list + for (Object element : currentNodeList) { + this.cur = element; + Object nextNode = chunk.accept(this); + if (nextNode != Prototype.UNDEFINED_VALUE) { + nextNodeList.add(nextNode); + } + } + if (nextNodeList.isEmpty()) { + this.cur = Prototype.UNDEFINED_VALUE; + } else { + // Flatten PathWrapperList if nested + nextNodeList = flattenPathWrapperList(nextNodeList); + this.cur = nextNodeList; + } + } else { + this.cur = chunk.accept(this); + } + } + + // At this point, cur is wrapped in one PathWrapperList at most + cur = stripPathWrapperListIfExists(cur); + return cur; + } + + private PathWrapperList flattenPathWrapperList(PathWrapperList pathWrapperList) { + PathWrapperList flattenedList = new PathWrapperList(); + for (Object element : pathWrapperList) { + if (element instanceof PathWrapperList nestedPathWrapperList) { + flattenedList.addAll(nestedPathWrapperList); // nest is at most one level deep + } else { + flattenedList.add(element); + } + } + return flattenedList; + } + + private static Object stripPathWrapperListIfExists(Object current) { + if (current instanceof PathWrapperList pathWrapperList) { + if (pathWrapperList.isEmpty()) { + return Prototype.UNDEFINED_VALUE; + } + // if the elements are lists, flatten them + // if not, collect them in a list + return pathWrapperList.stream() + .flatMap(e -> e instanceof List list ? list.stream() : List.of(e).stream()) + .toList(); + } else { + return current; + } + } + + // This List is used to wrap the node paths when the path indicates all elements of an array + // Defined this class to differentiate from ArrayList which is used for an event array + public static class PathWrapperList extends ArrayList { + } +} diff --git a/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueExtractionVisitor.java b/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueExtractionVisitor.java index 8a229898..473b138a 100644 --- a/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueExtractionVisitor.java +++ b/drools-ansible-rulebook-integration-protoextractor/src/main/java/org/drools/ansible/rulebook/integration/protoextractor/prototype/ValueExtractionVisitor.java @@ -11,6 +11,10 @@ import java.util.List; import java.util.Map; +/** + * This visitor is superseded by ValueCollectVisitor. + * This class will be dropped when we are sure that this is no longer needed. + */ public class ValueExtractionVisitor extends DefaultedVisitor { private final Object original; private Object cur;