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 <me@clifhouck.com>
This commit is contained in:
Clif Houck
2025-11-12 09:00:13 -06:00
parent aa96982e6d
commit c8abaad9fa
3 changed files with 299 additions and 0 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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)]
)
])