Merge "Generate network plan based on trait based networking config"

This commit is contained in:
Zuul
2025-12-04 22:55:37 +00:00
committed by Gerrit Code Review
2 changed files with 416 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
# 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 _
from ironic.common.trait_based_networking import base
from collections.abc import Callable
import itertools
def plan_network(
network_trait: base.NetworkTrait,
node_uuid: str,
node_ports: list[base.Port],
node_portgroups: list[base.Portgroup],
node_networks: list[base.Network]) -> list[base.RenderedAction]:
rendered_actions = []
# Order ports and portgroups by ID, newest first.
node_ports.sort(key=lambda port: port.id, reverse=True)
node_portgroups.sort(key=lambda portgroup: portgroup.id, reverse=True)
portlikes = [base.Port.from_ironic_port(port) for port in node_ports]
portgrouplikes = [base.Portgroup.from_ironic_portgroup(portgroup)
for portgroup in node_portgroups]
for trait_action in network_trait.actions:
match trait_action.action:
case base.Actions.ATTACH_PORT:
rendered_actions.extend(
plan_attach_portlike(
trait_action, node_uuid, portlikes,
node_networks, 'port',
lambda action_args:
base.AttachPort(*action_args)))
case base.Actions.ATTACH_PORTGROUP:
rendered_actions.extend(
plan_attach_portlike(
trait_action, node_uuid, portgrouplikes,
node_networks, 'portgroup',
lambda action_args:
base.AttachPortgroup(*action_args)))
# TODO(clif): Support bond_ports and group_and_attach_ports
case _:
rendered_actions.append(
base.NotImplementedAction(trait_action.action))
return rendered_actions
def plan_attach_portlike(
trait_action: base.NetworkTrait,
node_uuid: str,
node_portlikes: list[base.PrimordialPort],
node_networks: list[base.Network],
type_name: str,
action_func: Callable[[base.NetworkTrait, str, str, str],
base.RenderedAction]
) -> list[base.RenderedAction]:
actions = []
for (portlike, network) in itertools.product(node_portlikes,
node_networks):
if trait_action.matches(portlike, network):
actions.append(action_func((trait_action,
node_uuid,
portlike.uuid,
network.id)))
# No minimum count means match the first one.
if trait_action.min_count is None:
break
if trait_action.max_count == len(actions):
break
if len(actions) == 0:
return [base.NoMatch(trait_action,
node_uuid,
_(f"No ({type_name}, network) pairs matched "
"rule."))]
if (trait_action.min_count is not None
and len(actions) < trait_action.min_count):
return [base.NoMatch(trait_action,
node_uuid,
_(f"Not enough ({type_name}, network) pairs "
"matched to meet minimum count threshold. "
f"Matched {len(actions)} but min_count is "
f"{trait_action.min_count}."))]
return actions

View File

@@ -0,0 +1,317 @@
# 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.plan as tbn_plan
from ironic.tests import base
import ironic.tests.unit.common.trait_based_networking.utils as utils
from ddt import data
from ddt import ddt
from ddt import unpack
def annotate(name, *args):
class AnnotatedList(list):
pass
al = AnnotatedList([*args])
al.__name__ = name
return al
@ddt
class TraitBasedNetworkingPlanningTestCase(base.TestCase):
@data(
annotate(
"Match a port",
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse("port.vendor == 'clover'"),
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_port_uuid",
vendor="clover",
)
],
[tbn_base.Network("fake_net_id", "network_name", [])],
[
tbn_base.AttachPort(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.vendor == 'clover'")
),
"fake_node_uuid",
"fake_port_uuid",
"fake_net_id")
],
'port'
),
annotate(
"Match no ports",
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.vendor == 'cogwork'"),
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_port_uuid",
vendor="clover",
),
utils.FauxPortLikeObject(
uuid="fake_port_uuid2",
vendor="clover",
)
],
[tbn_base.Network("fake_net_id", "network_name", [])],
[
tbn_base.NoMatch(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.vendor == 'cogwork'")
),
"fake_node_uuid",
"No (port, network) pairs matched rule."
)
],
'port'
),
annotate(
"Match a specific port based on order",
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.vendor == 'cogwork'"),
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_port_uuid",
vendor="clover",
),
utils.FauxPortLikeObject(
uuid="fake_port_uuid3",
vendor="cogwork",
),
utils.FauxPortLikeObject(
uuid="fake_port_uuid2",
vendor="cogwork",
)
],
[tbn_base.Network("fake_net_id", "network_name", [])],
[
tbn_base.AttachPort(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.vendor == 'cogwork'")
),
"fake_node_uuid",
"fake_port_uuid3",
"fake_net_id"
)
],
'port'
),
annotate(
"Match one portgroup",
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORTGROUP,
tbn_base.FilterExpression.parse(
"port.physical_network == 'hypernet'"),
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_portgroup_uuid",
vendor="clover",
physical_network="hypernet",
)
],
[tbn_base.Network("fake_net_id", "network_name", [])],
[
tbn_base.AttachPortgroup(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORTGROUP,
tbn_base.FilterExpression.parse(
"port.physical_network == 'hypernet'"),
),
"fake_node_uuid",
"fake_portgroup_uuid",
"fake_net_id")
],
'portgroup'
),
annotate(
"Match no portgroups",
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORTGROUP,
tbn_base.FilterExpression.parse(
"port.category == 'blue'"),
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_portgroup_uuid",
category="green",
),
utils.FauxPortLikeObject(
uuid="fake_portgroup_uuid2",
category="red",
)
],
[tbn_base.Network("fake_net_id", "network_name", [])],
[
tbn_base.NoMatch(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORTGROUP,
tbn_base.FilterExpression.parse(
"port.category == 'blue'")
),
"fake_node_uuid",
"No (portgroup, network) pairs matched rule."
)
],
'portgroup'
),
annotate(
"Match a specific portgroup based on order",
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORTGROUP,
tbn_base.FilterExpression.parse(
"port.category == 'red'"),
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_portgroup_uuid",
vendor="clover",
),
utils.FauxPortLikeObject(
uuid="fake_portgroup_uuid3",
category="red",
),
utils.FauxPortLikeObject(
uuid="fake_portgroup_uuid2",
category="red",
)
],
[tbn_base.Network("fake_net_id", "network_name", [])],
[
tbn_base.AttachPortgroup(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORTGROUP,
tbn_base.FilterExpression.parse(
"port.category == 'red'"),
),
"fake_node_uuid",
"fake_portgroup_uuid3",
"fake_net_id")
],
'portgroup'
),
# TODO(clif): Test min_count and max_count
)
@unpack
def test_plan_attach_portlike(self,
trait_action: tbn_base.TraitAction,
node_uuid: str,
node_portlikes: list[utils.FauxPortLikeObject],
node_networks: list[tbn_base.Network],
expected_actions: list[tbn_base.RenderedAction],
type_name: str):
action_funcs = {
'port': lambda args: tbn_base.AttachPort(*args),
'portgroup': lambda args: tbn_base.AttachPortgroup(*args)
}
result_actions = tbn_plan.plan_attach_portlike(
trait_action,
node_uuid,
node_portlikes,
node_networks,
type_name,
action_funcs[type_name])
self.assertEqual(expected_actions, result_actions)
@data(
annotate("Attach one port",
tbn_base.NetworkTrait(
"CUSTOM_TRAIT",
[
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.physical_network == 'hypernet'"),
)
]
),
"fake_node_uuid",
[
utils.FauxPortLikeObject(
uuid="fake_port_uuid",
vendor="clover",
physical_network="hypernet",
)
],
[],
[tbn_base.Network("fake_net_id", "fake_net_name", [])],
[
tbn_base.AttachPort(
tbn_base.TraitAction(
"CUSTOM_TRAIT",
tbn_base.Actions.ATTACH_PORT,
tbn_base.FilterExpression.parse(
"port.physical_network == 'hypernet'"),
),
"fake_node_uuid",
"fake_port_uuid",
"fake_net_id")
],
),
)
@unpack
def test_plan_network(self,
network_trait: tbn_base.NetworkTrait,
node_uuid: str,
node_ports: list[tbn_base.Port],
node_portgroups: list[tbn_base.Portgroup],
node_networks: list,
expected_actions: list[tbn_base.RenderedAction]):
result_actions = tbn_plan.plan_network(
network_trait,
node_uuid,
node_ports,
node_portgroups,
node_networks)
self.assertEqual(expected_actions, result_actions)