# 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 ironic.conductor.task_manager import TaskManager
from ironic.objects.node import Node
from ironic.objects.port import Port
from ironic.objects.portgroup import Portgroup
from collections.abc import Callable
import itertools
[docs]
def filter_out_attached_portlikes(
portlikes: list[base.PrimordialPort],
actions: list[base.AttachAction]) -> list[base.PrimordialPort]:
"""Filters out attached portlikes based on generated attach actions"""
matched_uuids = set([action.portlike_uuid() for action in actions])
return [portlike for portlike in portlikes
if portlike.uuid not in matched_uuids]
[docs]
def is_no_match_list(actions: list[base.RenderedAction]) -> bool:
"""Check if a list contains only a NoMatch action"""
return len(actions) == 1 and isinstance(actions[0], base.NoMatch)
[docs]
def filter_out_grouped_ports(
portlikes: list[base.PrimordialPort],
actions: list[base.GroupAndAttachPorts]) -> list[base.PrimordialPort]:
"""Filters out ports that have been used to form dynamic portgroups"""
matched_uuids: set[str] = set()
for action in actions:
matched_uuids = matched_uuids.union(set(action.port_uuids))
return [portlike for portlike in portlikes
if portlike.uuid not in matched_uuids]
[docs]
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]:
"""Plan the network of a node based on TBN traits
:param network_trait: A single NetworkTrait to consider for planning.
:param node_uuid: The UUID of the node to which this plan applies.
:param ports: A list of Ports available to this node.
:param portgroups: A list of Portgroups available to this node.
:param node_networks: A list of networks available to this node.
:returns: A list of RenderedActions which should be executed by the
appropriate network driver.
"""
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:
new_actions = []
match trait_action.action:
case base.Actions.ATTACH_PORT:
new_actions = _plan_attach_portlike(
trait_action, node_uuid, portlikes,
node_networks, 'port',
lambda action_args:
base.AttachPort(*action_args))
if not is_no_match_list(new_actions):
portlikes = filter_out_attached_portlikes(portlikes,
new_actions)
case base.Actions.ATTACH_PORTGROUP:
new_actions = _plan_attach_portlike(
trait_action, node_uuid, portgrouplikes,
node_networks, 'portgroup',
lambda action_args:
base.AttachPortgroup(*action_args))
if not is_no_match_list(new_actions):
portgrouplikes = filter_out_attached_portlikes(
portgrouplikes,
new_actions)
case base.Actions.GROUP_AND_ATTACH_PORTS:
new_actions = _plan_group_and_attach_ports(
trait_action, node_uuid, portlikes,
node_networks)
if not is_no_match_list(new_actions):
portlikes = filter_out_grouped_ports(
portlikes,
new_actions)
# TODO(clif): Support bond_ports?
case _:
new_actions = [base.NotImplementedAction(trait_action.action)]
# NOTE(clif): Ordering of actions is up to the operator.
rendered_actions.extend(new_actions)
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]:
"""Mainly called by plan_netwrok to determine which portlikes to attach"""
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
def _plan_group_and_attach_ports(
trait_action: base.TraitAction,
node_uuid: str,
node_ports: list[base.Port],
node_networks: list[base.Network]) -> list[base.RenderedAction]:
"""Called by plan_network to handle group_and_attach_ports actions"""
matched_ports: list [base.Port] = []
# NOTE(clif): Valid min_count and max_counts for group_and_attach_ports
# actions are enforced at configuration ingestion time.
# NOTE(Clif): Only consider ports that allow dynamic_portgrouping.
available_ports: list[base.Port] = [
port for port in node_ports
if port.available_for_dynamic_portgroup
]
for port in available_ports:
# NOTE(clif): Network is not considered when grouping ports together.
# It will be when deciding which network to attach.
if trait_action.matches(port,
base.Network.universal_network()):
if len(matched_ports) > 0:
# NOTE(clif): Once we match a port, all subsequent ports must
# have the same physical_network. This is a requirement of
# Portgroups.
if matched_ports[0].physical_network \
== port.physical_network:
matched_ports.append(port)
else:
matched_ports.append(port)
if len(matched_ports) == trait_action.max_count:
break
if len(matched_ports) < trait_action.min_count:
return [base.NoMatch(trait_action,
node_uuid,
_("Couldn't match enough ports to form a "
f"dynamic portgroup for node '{node_uuid}'."
"Minimum ports needed is "
f"{trait_action.min_count}, and "
f"{len(matched_ports)} ports matched."))]
# NOTE(clif) Now decide which network we'll attach to. We consider the
# first port selected to stand in for the portgroup.
for network in node_networks:
if trait_action.matches(matched_ports[0], network):
return [base.GroupAndAttachPorts(
trait_action,
node_uuid,
[portlike.uuid for portlike in matched_ports],
network.id)]
# NOTE(clif) If we made it here, no networks matched.
return [base.NoMatch(trait_action,
node_uuid,
_("Enough ports matched to form a dynamic "
f"portgroup for node '{node_uuid}'. Unfortunately "
"no suitable networks were found to attach."))]
[docs]
def all_no_match(actions: list[base.RenderedAction]) -> bool:
"""Check if a list of actions contains only NoMatch actions"""
return all(isinstance(action, base.NoMatch) for action in actions)
[docs]
def order_traits(traits: list[base.NetworkTrait]) -> list[base.NetworkTrait]:
"""Sort a list of traits in ascending trait.order"""
return sorted(traits, key=lambda t: t.order)
# TODO(clif): Lifted from ironic.drivers.network.common to break a circular
# dependency. Maybe there's a common spot to lift this out to?
TENANT_VIF_KEY = 'tenant_vif_port_id'
[docs]
def is_portlike_attached(portlike: Port | Portgroup) -> bool:
"""Check if a portlike is attached or not"""
return (portlike.internal_info is not None
and portlike.internal_info.get(TENANT_VIF_KEY) is not None)
[docs]
def plan_vif_attach(traits: list[base.NetworkTrait],
task: TaskManager,
vif_info: dict) -> list[base.RenderedAction]:
"""Main entry point of TBN from _vif_attach_tbn in NeutronVIFPortIDMixIn
:param traits: A list of NetworkTraits that apply to the node being
considered.
:param task: A TaskManager which contains important information about the
node and network objects available.
:param vif_info: Information about the network (aka vif) which TBN will
use to plan actions.
:returns: A list of RenderedActions which should be executed by the
appropriate network driver.
"""
# TODO(clif): Take cues from get_free_port_like_object where appropriate.
net = base.Network.from_vif_info(vif_info)
# Filter out already attached ports and portgroups.
free_ports = [port for port in task.ports
if not is_portlike_attached(port)]
free_portgroups = [pg for pg in task.portgroups
if not is_portlike_attached(pg)]
for trait in order_traits(traits):
actions = plan_network(
trait,
task.node.uuid,
free_ports,
free_portgroups,
[net])
# If no actions matched, try the next trait.
if all_no_match(actions):
continue
else:
return actions
# TODO(clif): Maybe this should raise, because vif_attach raises when
# it can't find a free port or portgroup to attach.
return [base.NoMatch(
base.TraitAction(
'plan_vif_attach',
base.Actions.ATTACH_PORT,
base.FilterExpression.parse("port.category == 'plan_vif_attach'")),
task.node.uuid,
_("Could not find an applicable port or portgroup to "
f"attach to network '{net.id}' in any applicable "
"trait."))]
[docs]
def filter_traits_for_node(node: Node,
traits: list[base.NetworkTrait]
) -> list[base.NetworkTrait]:
"""Return a list of NetworkTraits that apply to a node"""
instance_traits = node.instance_info.get('traits') or []
return [trait for trait in traits if trait.name in set(instance_traits)]