Trait Based Networking Filter Expression Parsing and Base Models

Base models for the majority of the Trait Based Networking feature.

Adds a lark-based parser for filter expressions found in Trait Based
Networking configuration files.

Change-Id: I4414463c70d37a7c6b5a957941a2607b5c15ab9e
Signed-off-by: Clif Houck <me@clifhouck.com>
This commit is contained in:
Clif Houck
2025-11-12 08:56:40 -06:00
parent 23a63c5424
commit aa96982e6d
10 changed files with 1023 additions and 0 deletions
+16
View File
@@ -1131,3 +1131,19 @@ class ConfigDriveRegenerationFailure(IronicException):
"""Raised when we fail to handle configuration drive corrections."""
# NOTE(TheJulia): This is not intended to get raised to a user, but more
# so we handle known possible failure cases and don't fail horribly.
class TraitBasedNetworkingException(IronicException):
"""Raised when there's an issue with trait based networking feature."""
pass
class TBNComparatorPrefixMatchTypeMismatch(TraitBasedNetworkingException):
"""Comparator prefix match can only be used against a string."""
pass
class TBNAttributeRetrievalException(TraitBasedNetworkingException):
"""Specified attribute could not be found."""
_msg_fmt = _("Could not retrieve attribute %(attr_name)s from "
"passed object")
@@ -0,0 +1,355 @@
# 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.
import enum
import ironic.common.exception as exc
from ironic.common.i18n import _
import ironic.common.trait_based_networking.grammar.parser as tbn_parser
class Operator(enum.Enum):
AND = "&&"
OR = "||"
def eval(self, variable, value):
# NOTE(clif): These can operate on string values, and return the values
# themselves instead of a boolean!
match self.name:
case self.AND.name:
return variable and value
case self.OR.name:
return variable or value
def __str__(self):
return self.value
class Comparator(enum.Enum):
EQUALITY = "=="
INEQUALITY = "!="
GT_OR_EQ = ">="
GT = ">"
LT_OR_EQ = "<="
LT = "<"
PREFIX_MATCH = "=~"
def eval(self, variable, value):
# TODO(clif): Should we some sort of checking of variable type vs
# requested operator?
match self.name:
case self.EQUALITY.name:
return variable == value
case self.INEQUALITY.name:
return variable != value
case self.GT_OR_EQ.name:
return variable >= value
case self.GT.name:
return variable > value
case self.LT_OR_EQ.name:
return variable <= value
case self.LT.name:
return variable < value
case self.PREFIX_MATCH.name:
if isinstance(variable, str):
return variable.startswith(value)
raise exc.TBNComparatorPrefixMatchTypeMismatch(
_("Prefix match can only be used with variables "
"of type string")
)
def __str__(self):
return self.value
class Actions(enum.Enum):
ATTACH_PORT = "attach_port"
ATTACH_PORTGROUP = "attach_portgroup"
BOND_PORTS = "bond_ports"
GROUP_AND_ATTACH_PORTS = "group_and_attach_ports"
class Variables(enum.Enum):
NETWORK_NAME = "network.name"
NETWORK_TAGS = "network.tags"
PORT_ADDRESS = "port.address"
PORT_CATEGORY = "port.category"
PORT_IS_PORT = "port.is_port"
PORT_IS_PORTGROUP = "port.is_portgroup"
PORT_PHYSICAL_NETWORK = "port.physical_network"
PORT_VENDOR = "port.vendor"
def object_name(self):
return str(self).split(".")[0]
def attribute_name(self):
return str(self).split(".")[1]
def __str__(self):
return self.value
def retrieve_attribute(attribute_name, tbn_obj):
attribute = getattr(tbn_obj, attribute_name, None)
if attribute is None:
raise exc.TBNAttributeRetrievalException(attr_name=attribute_name)
return attribute
class FunctionExpression(object):
def __init__(self, variable):
self._variable = variable
def eval(self, port, network):
tbn_obj = port if self._variable.object_name() == "port" else network
attr_name = self._variable.attribute_name()
attr_func = retrieve_attribute(attr_name, tbn_obj)
return attr_func()
def __str__(self):
return f"{self._variable}"
class SingleExpression(object):
def __init__(self, variable, comparator, literal):
self._variable = variable
self._comparator = comparator
self._literal = literal
def eval(self, port, network):
tbn_obj = port if self._variable.object_name() == "port" else network
attr_name = self._variable.attribute_name()
attribute = retrieve_attribute(attr_name, tbn_obj)
return self._comparator.eval(attribute, self._literal)
def __str__(self):
return f"{self._variable} {self._comparator} '{self._literal}'"
class CompoundExpression(object):
def __init__(self, left_expression, operator, right_expression):
self._left_expression = left_expression
self._operator = operator
self._right_expression = right_expression
def eval(self, port, network):
left_result = self._left_expression.eval(port, network)
right_result = self._right_expression.eval(port, network)
match self._operator:
case Operator.OR:
return left_result or right_result
case Operator.AND:
return left_result and right_result
def __str__(self):
return (f"{self._left_expression} {self._operator} "
f"{self._right_expression}")
class ParenExpression(object):
def __init__(self, expression):
self._expression = expression
def eval(self, port, network):
return self._expression.eval(port, network)
def __str__(self):
return f"({self._expression})"
class FilterExpression(object):
def __init__(self, expression):
self._expression = expression
def eval(self, port, network):
return self._expression.eval(port, network)
def __str__(self):
return f"{self._expression}"
@classmethod
def parse(cls, expression):
tree = tbn_parser.FilterExpressionParser.parse(expression)
return tbn_parser.FilterExpressionTransformer().transform(tree)
def __eq__(self, other):
return str(self) == str(other)
class TraitAction(object):
NECESSARY_KEYS = [
'action',
'filter',
]
OPTIONAL_KEYS = [
'max_count',
'min_count',
]
ALL_KEYS = OPTIONAL_KEYS + NECESSARY_KEYS
def __init__(self, trait_name, action, filter_expression,
min_count=None, max_count=None):
self.trait_name = trait_name
self.action = action
self.filter_expression = filter_expression
self.min_count = min_count
self.max_count = max_count
def matches(self, portlike, network):
"""Check if filter expression matches the port, network pairing."""
return self.filter_expression.eval(portlike, network)
def __eq__(self, other):
return (self.trait_name == other.trait_name
and self.action == other.action
and self.filter_expression == other.filter_expression
and self.min_count == other.min_count
and self.max_count == other.max_count)
class NetworkTrait(object):
def __init__(self, name, actions):
self.name = name
self.actions = actions
def __eq__(self, other):
if self.name != other.name:
return False
for action in self.actions:
match_found = False
for other_action in other.actions:
if action == other_action:
match_found = True
break
if not match_found:
return False
return True
class PrimordialPort(object):
def __init__(self, ironic_port_like_obj):
# NOTE(clif): Both ironic port and portgroups should support the
# attributes below.
self.id = ironic_port_like_obj.id
self.uuid = ironic_port_like_obj.uuid
self.address = ironic_port_like_obj.address
self.category = ironic_port_like_obj.category
self.physical_network = ironic_port_like_obj.physical_network
self.vendor = ironic_port_like_obj.vendor
class Port(PrimordialPort):
@classmethod
def from_ironic_port(cls, ironic_port):
return Port(ironic_port)
def is_port(self):
return True
def is_portgroup(self):
return False
class Portgroup(PrimordialPort):
@classmethod
def from_ironic_portgroup(cls, ironic_portgroup):
return Portgroup(ironic_portgroup)
def is_port(self):
return False
def is_portgroup(self):
return True
# TODO(Clif): Draw from VIFs
class Network(object):
def __init__(self, network_id, name, tags):
self.id = network_id
self.name = name
self.tags = tags
class RenderedAction(object):
def __init__(self, trait_action, node_uuid):
self._trait_action = trait_action
self._node_uuid = node_uuid
def __eq__(self, other):
return (self._trait_action == other._trait_action
and self._node_uuid == other._node_uuid)
class AttachPort(RenderedAction):
def __init__(self, trait_action, node_uuid, port_uuid, network_id):
super().__init__(trait_action, node_uuid)
self._port_uuid = port_uuid
self._network_id = network_id
def __str__(self):
return _(f"Attach port '{self._port_uuid}' on node "
f"'{self._node_uuid}' to network '{self._network_id}' "
f"via trait {self._trait_action.trait_name}")
def __eq__(self, other):
return (isinstance(other, AttachPort)
and self._port_uuid == other._port_uuid
and self._network_id == other._network_id
and super().__eq__(other))
class AttachPortgroup(RenderedAction):
def __init__(self, trait_action, node_uuid, portgroup_uuid, network_id):
super().__init__(trait_action, node_uuid)
self._portgroup_uuid = portgroup_uuid
self._network_id = network_id
def __str__(self):
return _(f"Attach portgroup '{self._portgroup_uuid}' on node "
f"'{self._node_uuid}' to network '{self._network_id}'"
f"via trait {self._trait_action.trait_name}")
def __eq__(self, other):
return (isinstance(other, AttachPortgroup)
and self._portgroup_uuid == other._portgroup_uuid
and self._network_id == other._network_id
and super().__eq__(other))
class NoMatch(RenderedAction):
def __init__(self, trait_action, node_uuid, reason):
super().__init__(trait_action, node_uuid)
self._reason = reason
def __str__(self):
return _(f"No match found for action under trait "
f"'{self._trait_action.trait_name}' "
f"on node '{self._node_uuid}': {self._reason}")
def __eq__(self, other):
return (isinstance(other, NoMatch)
and self._reason == other._reason
and super().__eq__(other))
class NotImplementedAction(RenderedAction):
def __init__(self, action):
self._action = action
def __str__(self):
return _(f"Action '{self._action.value}' not yet implemented.")
@@ -0,0 +1,114 @@
# 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.
import ironic.common.trait_based_networking.base as tbn_base
import lark
FILTER_EXPRESSION_GRAMMAR = r"""
filter_expression: single_expression
| compound_expression
| paren_expression
| function_expression
function_expression: function
single_expression: variable_name comparator string_literal
compound_expression: filter_expression ( boolean_operator filter_expression )+
paren_expression: "(" filter_expression ")"
boolean_operator: "&&" -> op_and
| "||" -> op_or
comparator: "==" -> equality
| "!=" -> inequality
| ">=" -> gt_or_eq
| ">" -> gt
| "<=" -> lt_or_eq
| "<" -> lt
| "=~" -> prefix_match
function: "is_port" -> port_is_port
| "is_portgroup" -> port_is_portgroup
string_literal: /\'[A-Za-z0-9_\-\.]*\'/
variable_name: /[a-z]+\.[a-z\_]+/
%import common.WS
%ignore WS
"""
FILTER_EXPRESSION_GRAMMAR_START_RULE = 'filter_expression'
FilterExpressionParser = lark.Lark(FILTER_EXPRESSION_GRAMMAR,
start=FILTER_EXPRESSION_GRAMMAR_START_RULE)
class FilterExpressionTransformer(lark.Transformer):
def op_and(self, items):
return tbn_base.Operator.AND
def op_or(self, items):
return tbn_base.Operator.OR
def equality(self, items):
return tbn_base.Comparator.EQUALITY
def inequality(self, items):
return tbn_base.Comparator.INEQUALITY
def gt_or_eq(self, items):
return tbn_base.Comparator.GT_OR_EQ
def gt(self, items):
return tbn_base.Comparator.GT
def lt_or_eq(self, items):
return tbn_base.Comparator.LT_OR_EQ
def lt(self, items):
return tbn_base.Comparator.LT
def prefix_match(self, items):
return tbn_base.Comparator.PREFIX_MATCH
def single_expression(self, items):
return tbn_base.SingleExpression(items[0], items[1], items[2])
def compound_expression(self, items):
return tbn_base.CompoundExpression(items[0], items[1], items[2])
def paren_expression(self, items):
return tbn_base.ParenExpression(items[0])
def filter_expression(self, items):
return tbn_base.FilterExpression(items[0])
def port_is_port(self, items):
return tbn_base.Variables.PORT_IS_PORT
def port_is_portgroup(self, items):
return tbn_base.Variables.PORT_IS_PORTGROUP
def function_expression(self, items):
return tbn_base.FunctionExpression(items[0])
def variable_name(self, items):
token = items[0]
return tbn_base.Variables(token.value)
def string_literal(self, items):
token = items[0]
# Strip ' characters from literal.
return token.value[1:-1]
@@ -0,0 +1,346 @@
# 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.
import ironic.common.exception as exc
import ironic.common.trait_based_networking.base as tbn
from ironic.tests import base
import ironic.tests.unit.common.trait_based_networking.utils as tbn_test_utils
import itertools
class TraitBasedNetworkingBaseTestCase(base.TestCase):
def test_filter_expression_str_representation(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_ADDRESS, tbn.Comparator.EQUALITY, "test"
)
self.assertEqual("port.address == 'test'", str(exp))
def test_filter_object_missing_attribute_raises(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_ADDRESS, tbn.Comparator.EQUALITY, "test"
)
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(address=None))
net = tbn_test_utils.FauxNetwork()
self.assertRaises(exc.TraitBasedNetworkingException, exp.eval,
obj, net)
def test_filter_comparator_eval_equality(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_ADDRESS, tbn.Comparator.EQUALITY, "test"
)
obj = tbn.Port.from_ironic_port(tbn_test_utils.FauxPortLikeObject())
net = tbn_test_utils.FauxNetwork()
self.assertTrue(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(address="bad")
)
self.assertFalse(exp.eval(obj, net))
def test_filter_comparator_eval_inequality(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_ADDRESS, tbn.Comparator.INEQUALITY, "test"
)
obj = tbn.Port.from_ironic_port(tbn_test_utils.FauxPortLikeObject())
net = tbn_test_utils.FauxNetwork()
self.assertFalse(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(address="bad")
)
self.assertTrue(exp.eval(obj, net))
def test_filter_comparator_eval_greater_than_or_equal(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_CATEGORY, tbn.Comparator.GT_OR_EQ, "test"
)
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(category="test2")
)
net = tbn_test_utils.FauxNetwork()
self.assertTrue(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(category="test")
)
self.assertTrue(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(category="abcd")
)
self.assertFalse(exp.eval(obj, net))
def test_filter_comparator_eval_greater_than(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_CATEGORY, tbn.Comparator.GT, "test"
)
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(category="test2")
)
net = tbn_test_utils.FauxNetwork()
self.assertTrue(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(category="test")
)
self.assertFalse(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(category="abcd")
)
self.assertFalse(exp.eval(obj, net))
def test_filter_comparator_eval_less_than_or_equal(self):
exp = tbn.SingleExpression(
tbn.Variables.NETWORK_NAME, tbn.Comparator.LT_OR_EQ, "test"
)
port = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject()
)
net = tbn.Network("fake_id", "test2", [])
self.assertFalse(exp.eval(port, net))
net = tbn.Network("fake_id", "test", [])
self.assertTrue(exp.eval(port, net))
net = tbn.Network("fake_id", "abcd", [])
self.assertTrue(exp.eval(port, net))
def test_filter_comparator_eval_less_than(self):
exp = tbn.SingleExpression(
tbn.Variables.NETWORK_NAME, tbn.Comparator.LT, "test"
)
port = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject()
)
net = tbn.Network("fake_id", "test2", [])
self.assertFalse(exp.eval(port, net))
net = tbn.Network("fake_id", "test", [])
self.assertFalse(exp.eval(port, net))
net = tbn.Network("fake_id", "abcd", [])
self.assertTrue(exp.eval(port, net))
def test_filter_comparator_eval_prefix_match(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_VENDOR, tbn.Comparator.PREFIX_MATCH, "some"
)
net = tbn_test_utils.FauxNetwork()
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(vendor="some_vendor")
)
self.assertTrue(exp.eval(obj, net))
obj = tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(vendor="another_vendor")
)
self.assertFalse(exp.eval(obj, net))
def test_filter_comparator_eval_prefix_match_bad_variable_type(self):
exp = tbn.SingleExpression(
tbn.Variables.PORT_IS_PORT, tbn.Comparator.PREFIX_MATCH, "some"
)
net = tbn_test_utils.FauxNetwork()
obj = tbn.Port.from_ironic_port(tbn_test_utils.FauxPortLikeObject())
self.assertTrue(obj.is_port())
self.assertRaises(exc.TraitBasedNetworkingException,
exp.eval, obj, net)
def test_filter_operator_eval_and(self):
exp = tbn.CompoundExpression(
tbn.FunctionExpression(tbn.Variables.PORT_IS_PORT),
tbn.Operator.AND,
tbn.FunctionExpression(tbn.Variables.PORT_IS_PORT),
)
net = tbn_test_utils.FauxNetwork()
obj = tbn.Port.from_ironic_port(tbn_test_utils.FauxPortLikeObject())
self.assertTrue(exp.eval(obj, net))
obj = tbn.Portgroup.from_ironic_portgroup(
tbn_test_utils.FauxPortLikeObject())
self.assertFalse(exp.eval(obj, net))
def test_filter_operator_eval_or(self):
exp = tbn.CompoundExpression(
tbn.FunctionExpression(tbn.Variables.PORT_IS_PORT),
tbn.Operator.OR,
tbn.FunctionExpression(tbn.Variables.PORT_IS_PORT),
)
net = tbn_test_utils.FauxNetwork()
obj = tbn.Port.from_ironic_port(tbn_test_utils.FauxPortLikeObject())
self.assertTrue(exp.eval(obj, net))
obj = tbn.Portgroup.from_ironic_portgroup(
tbn_test_utils.FauxPortLikeObject())
self.assertFalse(exp.eval(obj, net))
exp = tbn.CompoundExpression(
tbn.FunctionExpression(tbn.Variables.PORT_IS_PORT),
tbn.Operator.OR,
tbn.FunctionExpression(tbn.Variables.PORT_IS_PORTGROUP),
)
obj = tbn.Port.from_ironic_port(tbn_test_utils.FauxPortLikeObject())
self.assertTrue(exp.eval(obj, net))
def test_attach_port_equality(self):
self.assertEqual(
tbn.AttachPort(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORT,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_port_uuid",
"fake_network_id"),
tbn.AttachPort(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORT,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_port_uuid",
"fake_network_id")
)
self.assertNotEqual(
tbn.AttachPort(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORT,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_port_uuid",
"fake_network_id"),
tbn.AttachPort(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORT,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"real_node_uuid",
"fake_port_uuid",
"fake_network_id")
)
def test_attach_portgroup_equality(self):
self.assertEqual(
tbn.AttachPortgroup(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORTGROUP,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_portgroup_uuid",
"fake_network_id"),
tbn.AttachPortgroup(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORTGROUP,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_portgroup_uuid",
"fake_network_id")
)
self.assertNotEqual(
tbn.AttachPortgroup(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORTGROUP,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_portgroup_uuid",
"fake_network_id"),
tbn.AttachPortgroup(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORTGROUP,
tbn.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"real_portgroup_uuid",
"fake_network_id"),
)
def test_rendered_actions_type_mismatch_equality(self):
types = [
tbn.RenderedAction(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORT,
tbn.FilterExpression.parse("port.vendor == 'cogwork'")),
"fake_node_uuid"
),
tbn.AttachPort(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORT,
tbn.FilterExpression.parse("port.vendor == 'cogwork'")),
"fake_node_uuid",
"fake_port_uuid",
"fake_network_id"
),
tbn.AttachPortgroup(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORTGROUP,
tbn.FilterExpression.parse("port.vendor == 'cogwork'")),
"fake_node_uuid",
"fake_portgroup_uuid",
"fake_network_id"
),
tbn.NoMatch(
tbn.TraitAction(
"CUSTOM_TRAIT",
tbn.Actions.ATTACH_PORTGROUP,
tbn.FilterExpression.parse("port.vendor == 'cogwork'")),
"fake_node_uuid",
"didn't match"
)
]
# Make sure we can compare differing types of RenderedActions and not
# blow up. Inequal types of RenderedActions must not evaluate as equal
# with __eq__/==.
for prod in itertools.product(types, repeat=2):
if type(prod[0]) is type(prod[1]):
self.assertEqual(prod[0], prod[1])
else:
self.assertNotEqual(prod[0], prod[1])
@@ -0,0 +1,150 @@
# 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.
import ironic.common.trait_based_networking.base as tbn
import ironic.common.trait_based_networking.grammar.parser as tbn_parser
from ironic.tests import base
import ironic.tests.unit.common.trait_based_networking.utils as tbn_test_utils
import lark
from dataclasses import dataclass
class TraitBasedNetworkingFilterParserTestCase(base.TestCase):
def test_grammar_acceptable_to_lark(self):
parser = lark.Lark(
tbn_parser.FILTER_EXPRESSION_GRAMMAR,
start=tbn_parser.FILTER_EXPRESSION_GRAMMAR_START_RULE)
self.assertIsNotNone(parser)
def test_filter_expression_parser(self):
@dataclass
class SubTestCase(object):
description: str
expression: str
port_args: list[dict]
# TODO(clif): Also test networks here
expected_eval_result: list[bool]
subtests = [
SubTestCase(
"Basic single expression",
"port.vendor == 'vendor_string'",
[{"vendor": "vendor_string"}],
[True],
),
SubTestCase(
"Compound expressions with parenthesis",
(
"port.vendor == 'green' "
"&& port.category == 'storage' "
"&& (port.physical_network =~ 'storage' "
"|| port.address == '192.168.1.1')"
),
[
{
"vendor": "green",
"address": "192.168.1.1",
"physical_network": "storagenet",
"category": "storage",
},
{
"vendor": "green",
"address": "192.168.1.1",
"physical_network": "othernet",
"category": "storage",
},
{
"vendor": "brown",
"address": "192.168.1.1",
"physical_network": "storagenet",
"category": "storage_alpha",
},
],
[True, True, False],
),
SubTestCase(
"Part 1 ensuring parens are respected",
(
"(port.vendor == 'green' "
"&& port.category == 'test') "
"|| port.address == '192.168.1.1'"
),
[
{
"vendor": "brown",
"address": "192.168.1.1",
"category": "prod",
},
{
"vendor": "brown",
"address": "192.168.1.1",
"category": "prod",
},
],
[True, True],
),
SubTestCase(
"Part 2 ensuring parens are respected",
(
"port.vendor == 'green' "
"&& (port.category == 'test' "
"|| port.address == '192.168.1.1')"
),
[
{
"vendor": "brown",
"address": "192.168.1.1",
"category": "prod",
},
{
"vendor": "brown",
"address": "192.168.1.1",
"category": "prod",
},
],
[False, False],
),
# TODO(clif): Test case about network variables
]
for subtest in subtests:
with self.subTest(subtest=subtest):
# Assert expression parses correctly.
result = tbn.FilterExpression.parse(subtest.expression)
self.assertIsNotNone(result)
# Assert the transformation of the parse tree to
# trait based networking objects renders back to the original
# expression exactly.
self.assertEqual(str(result), subtest.expression)
self.assertEqual(
len(subtest.port_args), len(subtest.expected_eval_result)
)
# Assert the evaluation of the expression returns the correct
# result.
for i in range(0, len(subtest.port_args)):
self.assertEqual(
result.eval(
tbn.Port.from_ironic_port(
tbn_test_utils.FauxPortLikeObject(
**subtest.port_args[i]
)
),
tbn.Network("test_net_id", "test_network", []),
),
subtest.expected_eval_result[i],
)
@@ -0,0 +1,41 @@
# 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.
class FauxPortLikeObject(object):
def __init__(
self,
port_id="fake_id",
uuid="fake_uuid",
address="test",
category="cat",
physical_network="test_physnet",
vendor="fake_vendor",
):
self.id = port_id
self.uuid = uuid
self.address = address
self.category = category
self.physical_network = physical_network
self.vendor = vendor
class FauxNetwork(object):
def __init__(
self,
network_id="fake_net_id",
name="test_network",
tags=['test_tag'],
):
self.id = network_id
self.name = name
self.tags = tags
+1
View File
@@ -48,3 +48,4 @@ websockify>=0.9.0 # LGPLv3
PyYAML>=6.0.2 # MIT
cheroot>=10.0.1 # BSD
cotyledon>=2.0.0 # Apache-2.0
lark>=1.2.2 # MIT