From c8abaad9fa44bb60c317a70be4049ceed55a922e Mon Sep 17 00:00:00 2001 From: Clif Houck Date: Wed, 12 Nov 2025 09:00:13 -0600 Subject: [PATCH] Configuration file for Trait Based Networking Adds a configuration file class for Trait Based Networking. The class can read, validate, and parse a YAML config file conforming to the expected structure of a TBN configuration file. Parsing renders the configuration to TBN objects. Change-Id: I69802006274d2373e73ba3d2779c29e365caea85 Signed-off-by: Clif Houck --- etc/ironic/trait_based_networks.yaml.sample | 21 ++ .../trait_based_networking/config_file.py | 95 +++++++++ .../test_config_file.py | 183 ++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 etc/ironic/trait_based_networks.yaml.sample create mode 100644 ironic/common/trait_based_networking/config_file.py create mode 100644 ironic/tests/unit/common/trait_based_networking/test_config_file.py diff --git a/etc/ironic/trait_based_networks.yaml.sample b/etc/ironic/trait_based_networks.yaml.sample new file mode 100644 index 0000000000..ee75f4f677 --- /dev/null +++ b/etc/ironic/trait_based_networks.yaml.sample @@ -0,0 +1,21 @@ +CUSTOM_TRAIT_NAME: + - action: bond_ports + filter: port.vendor == 'vendor_string' + min_count: 2 +CUSTOM_DIRECT_ATTACH_A_PURPLE_TO_STORAGE: + - action: attach_port + filter: port.vendor == 'purple' && network.name == 'storage' +CUSTOM_BOND_PURPLE_BY_2: + - action: group_and_attach_ports + filter: port.vendor == 'purple' + max_count: 2 +CUSTOM_BOND_GREEN_STORAGE_TO_STORAGE_BY_2: + - action: group_and_attach_ports + filter: port.vendor == 'green' && port.category == 'storage' && ( network.name =~ 'storage' || network.tags =~ 'storage' ) + max_count: 2 + min_count: 2 +CUSTOM_USE_PHYSNET_A_OR_B: + - action: attach_port + filter: port.physical_network == 'fabric_a' && network.tags == 'a' + - action: attach_port + filter: port.physical_network == 'fabric_b' && network.tags == 'b' diff --git a/ironic/common/trait_based_networking/config_file.py b/ironic/common/trait_based_networking/config_file.py new file mode 100644 index 0000000000..22b555f4fe --- /dev/null +++ b/ironic/common/trait_based_networking/config_file.py @@ -0,0 +1,95 @@ +# 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.i18n import _ +import ironic.common.trait_based_networking.base as base + +import yaml + + +class ConfigFile(object): + def __init__(self, filename): + self._filename = filename + # TODO(clif): Do this here, or defer to clients of class calling these? + self.read() + + def read(self): + with open(self._filename, 'r') as file: + self._contents = yaml.safe_load(file) + + def validate(self): + """Check that contents conform to TBN expectations.""" + reasons = [] + valid = True + for key, value_list in self._contents.items(): + if not isinstance(value_list, list): + reasons.append( + _(f"'{key}' trait does not consist of a list of actions")) + valid = False + continue + for v in value_list: + # Check necessary keys are present. + for n in base.TraitAction.NECESSARY_KEYS: + if n not in v: + reasons.append( + _(f"'{key}' trait is missing '{n}' key")) + valid = False + + # Check for errant keys. + for sub_key in v.keys(): + if sub_key not in base.TraitAction.ALL_KEYS: + reasons.append( + _(f"'{key}' trait action has unrecognized key " + f"'{sub_key}'")) + valid = False + + # Make sure action is valid + if 'action' in v: + action = v['action'] + try: + base.Actions(action) + except Exception: + valid = False + reasons.append( + _(f"'{key}' trait action has unrecognized action " + f"'{action}'")) + + # Does the filter parse? + if 'filter' in v: + try: + base.FilterExpression.parse(v['filter']) + except Exception: + valid = False + # TODO(clif): Surface exception text in reason below? + reasons.append( + _(f"'{key}' trait action has malformed " + f"filter expression: '{v['filter']}'")) + + return valid, reasons + + def parse(self): + """Render contents of configuration file as TBN objects""" + self._traits = [] + for trait_name, actions in self._contents.items(): + parsed_actions = [] + for action in actions: + parsed_actions.append(base.TraitAction( + trait_name, + base.Actions(action['action']), + base.FilterExpression.parse(action['filter']), + min_count=action.get('min_count', None), + max_count=action.get('max_count', None))) + self._traits.append(base.NetworkTrait(trait_name, parsed_actions)) + + def traits(self): + return self._traits diff --git a/ironic/tests/unit/common/trait_based_networking/test_config_file.py b/ironic/tests/unit/common/trait_based_networking/test_config_file.py new file mode 100644 index 0000000000..7c1f7a14f8 --- /dev/null +++ b/ironic/tests/unit/common/trait_based_networking/test_config_file.py @@ -0,0 +1,183 @@ +# 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 ironic.common.trait_based_networking.config_file as cf + +from ironic.tests import base + +from dataclasses import dataclass +import tempfile + + +EXAMPLE_CONFIG_FILE_LOCATION = "etc/ironic/trait_based_networks.yaml.sample" + + +class TraitBasedNetworkingConfigFileTestCase(base.TestCase): + def setUp(self): + super(TraitBasedNetworkingConfigFileTestCase, self).setUp() + self.tmpdir = tempfile.TemporaryDirectory() + + self.addTypeEqualityFunc( + tbn_base.NetworkTrait, + lambda first, second, msg=None: first == second + ) + + def test_load_example_config_file(self): + config_file = cf.ConfigFile(EXAMPLE_CONFIG_FILE_LOCATION) + self.assertIsNotNone(config_file) + self.assertTrue(config_file.validate()[0]) + config_file.parse() + + def test_validate(self): + @dataclass + class SubTestCase(object): + description: str + contents: str + expected_valid: bool + expected_reasons: list[str] + + subtests = [ + SubTestCase( + "Valid - single trait", + ("CUSTOM_TRAIT_NAME:\n" + " - action: bond_ports\n" + " filter: port.vendor == 'vendor_string'\n" + " min_count: 2\n"), + True, + [], + ), + SubTestCase( + "Valid - Several traits", + ("CUSTOM_TRAIT_NAME:\n" + " - action: bond_ports\n" + " filter: port.vendor == 'vendor_string'\n" + " min_count: 2\n" + "CUSTOM_TRAIT_2:\n" + " - action: attach_port\n" + " filter: port.vendor != 'vendor_string'\n" + " max_count: 2\n" + "CUSTOM_TRAIT_3:\n" + " - action: attach_port\n" + " filter: port.vendor != 'vendor_string'\n" + " max_count: 2\n"), + True, + [], + ), + SubTestCase( + "Invalid - Missing trait has required entry missing", + ("trait_name:\n" + " - action: bond_ports\n"), + False, + ["'trait_name' trait is missing 'filter' key"], + ), + SubTestCase( + "Invalid - Unrecognized trait entry", + ("CUSTOM_TRAIT_NAME:\n" + " - action: bond_ports\n" + " filter: port.vendor == 'vendor_string'\n" + " min_count: 2\n" + " wrong: hi\n"), + False, + [("'CUSTOM_TRAIT_NAME' trait action has unrecognized key " + "'wrong'")], + ), + SubTestCase( + "Invalid - Unrecognized action", + ("CUSTOM_TRAIT_NAME:\n" + " - action: invalid\n" + " filter: port.vendor == 'vendor_string'\n" + " min_count: 2\n"), + False, + ["'CUSTOM_TRAIT_NAME' trait action has unrecognized action " + "'invalid'"], + ), + SubTestCase( + "Invalid - trait does not consist of a list of actions", + ("CUSTOM_TRAIT_NAME:\n" + " action: bond_ports\n" + " filter: port.vendor == 'vendor_string'\n" + " min_count: 2\n"), + False, + [("'CUSTOM_TRAIT_NAME' trait does not consist of a list " + "of actions")], + ), + SubTestCase( + "Invalid - trait action has malformed filter expression", + ("CUSTOM_TRAIT_NAME:\n" + " - action: bond_ports\n" + " filter: port.vendor &= 'vendor_string'\n" + " min_count: 2\n"), + False, + [("'CUSTOM_TRAIT_NAME' trait action has malformed filter " + "expression: 'port.vendor &= 'vendor_string''")], + ), + SubTestCase( + "Invalid - several things wrong", + ("CUSTOM_TRAIT_NAME:\n" + " - filter: port.vendor &= 'vendor_string'\n" + " min_count: 2\n" + " wrong: oops\n"), + False, + [("'CUSTOM_TRAIT_NAME' trait action has malformed filter " + "expression: 'port.vendor &= 'vendor_string''"), + "'CUSTOM_TRAIT_NAME' trait is missing 'action' key", + ("'CUSTOM_TRAIT_NAME' trait action has unrecognized key " + "'wrong'")], + ), + ] + + for subtest in subtests: + with self.subTest(subtest=subtest): + with tempfile.NamedTemporaryFile( + mode='w', + dir=self.tmpdir.name, + delete=False) as tmpfile: + tmpfile.write(subtest.contents) + tmpfile.close() + config_file = cf.ConfigFile(tmpfile.name) + valid, reasons = config_file.validate() + self.assertEqual(subtest.expected_valid, valid) + self.assertCountEqual(subtest.expected_reasons, reasons) + + def test_parse(self): + contents = ( + "CUSTOM_TRAIT_NAME:\n" + " - action: bond_ports\n" + " filter: port.vendor == 'vendor_string'\n" + " min_count: 2\n") + + with tempfile.NamedTemporaryFile( + mode='w', + dir=self.tmpdir.name, + delete=False) as tmpfile: + tmpfile.write(contents) + tmpfile.close() + config_file = cf.ConfigFile(tmpfile.name) + valid, reasons = config_file.validate() + self.assertTrue(valid) + self.assertCountEqual(reasons, []) + + config_file.parse() + result = config_file.traits() + + self.assertCountEqual(result, [ + tbn_base.NetworkTrait( + "CUSTOM_TRAIT_NAME", + [tbn_base.TraitAction( + "CUSTOM_TRAIT_NAME", + tbn_base.Actions("bond_ports"), + tbn_base.FilterExpression.parse( + "port.vendor == 'vendor_string'"), + min_count=2)] + ) + ])