Skip to content

Commit 9ff91f9

Browse files
committed
Add update_baremetal_port ironic inspection hook
1 parent 97b352e commit 9ff91f9

File tree

6 files changed

+345
-0
lines changed

6 files changed

+345
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from ironic.objects import port as ironic_port
2+
from oslo_utils import uuidutils
3+
4+
from ironic_understack.update_baremetal_port import UpdateBaremetalPortsHook
5+
6+
_INTERFACE_1 = {
7+
"name": "example1",
8+
"mac_address": "11:11:11:11:11:11",
9+
"ipv4_address": "1.1.1.1",
10+
"lldp": [
11+
(0, ""),
12+
(1, "04885a92ec5459"),
13+
(2, "0545746865726e6574312f3138"),
14+
(3, "0078"),
15+
(5, "6632302d332d32662e69616433"),
16+
],
17+
}
18+
19+
_PLUGIN_DATA = {"all_interfaces": {"example1": _INTERFACE_1}}
20+
21+
_INVENTORY = {"interfaces": [_INTERFACE_1]}
22+
23+
24+
def test_with_valid_data(mocker):
25+
node_uuid = uuidutils.generate_uuid()
26+
mock_node = mocker.Mock()
27+
mock_task = mocker.Mock(node=mock_node)
28+
mock_port = mocker.Mock(
29+
uuid=uuidutils.generate_uuid(),
30+
node_id=node_uuid,
31+
address="11:11:11:11:11:11",
32+
local_link_connection={},
33+
physical_network="original_value",
34+
)
35+
mocker.patch(
36+
"ironic_understack.update_baremetal_port.objects.port.Port.get_by_address",
37+
return_value=mock_port,
38+
)
39+
40+
UpdateBaremetalPortsHook().__call__(mock_task, _INVENTORY, _PLUGIN_DATA)
41+
42+
assert mock_port.local_link_connection == {
43+
"port_id": "Ethernet1/18",
44+
"switch_id": "88:5a:92:ec:54:59",
45+
"switch_info": "f20-3-2f.iad3",
46+
}
47+
assert mock_port.physical_network == "f20-3-storage"
48+
49+
mock_port.save.assert_called()
50+
mock_node.remove_trait.assert_not_called()
51+
mock_node.add_trait.assert_called_once_with("CUSTOM_STORAGE_SWITCH")
52+
mock_node.save.assert_called_once()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pytest
2+
3+
from ironic_understack.vlan_group_name_convention import vlan_group_name
4+
5+
6+
def test_vlan_group_name_valid_switches():
7+
assert vlan_group_name("a1-1-1") == "a1-1-network"
8+
assert vlan_group_name("a1-2-1") == "a1-2-network"
9+
assert vlan_group_name("b12-1") == "b12-network"
10+
assert vlan_group_name("a2-12-1") == "a2-12-network"
11+
assert vlan_group_name("a2-12-2") == "a2-12-network"
12+
assert vlan_group_name("a2-12-1f") == "a2-12-storage"
13+
assert vlan_group_name("a2-12-2f") == "a2-12-storage"
14+
assert vlan_group_name("a2-12-3f") == "a2-12-storage-appliance"
15+
assert vlan_group_name("a2-12-4f") == "a2-12-storage-appliance"
16+
assert vlan_group_name("a2-12-1d") == "a2-12-bmc"
17+
18+
19+
def test_vlan_group_name_with_domain():
20+
assert vlan_group_name("a2-12-1.iad3.rackspace.net") == "a2-12-network"
21+
assert vlan_group_name("a2-12-1f.lon3.rackspace.net") == "a2-12-storage"
22+
23+
24+
def test_vlan_group_name_case_insensitive():
25+
assert vlan_group_name("A2-12-1F") == "a2-12-storage"
26+
assert vlan_group_name("A2-12-1") == "a2-12-network"
27+
28+
29+
def test_vlan_group_name_invalid_format():
30+
with pytest.raises(ValueError, match="Unknown switch name format"):
31+
vlan_group_name("invalid")
32+
33+
with pytest.raises(ValueError, match="Unknown switch name format"):
34+
vlan_group_name("")
35+
36+
37+
def test_vlan_group_name_unknown_suffix():
38+
with pytest.raises(ValueError, match="Unknown switch suffix"):
39+
vlan_group_name("a2-12-99")
40+
41+
with pytest.raises(ValueError, match="Unknown switch suffix"):
42+
vlan_group_name("a2-12-5f")
43+
44+
with pytest.raises(ValueError, match="Unknown switch suffix"):
45+
vlan_group_name("a2-12-xyz")
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import binascii
2+
from typing import Any
3+
4+
import netaddr
5+
import openstack
6+
from construct import core
7+
from ironic import objects
8+
from ironic.common import exception
9+
from ironic.drivers.modules.inspector import lldp_tlvs
10+
from ironic.drivers.modules.inspector.hooks import base
11+
from oslo_log import log as logging
12+
13+
import ironic_understack.vlan_group_name_convention
14+
15+
LOG = logging.getLogger(__name__)
16+
17+
18+
class UpdateBaremetalPortsHook(base.InspectionHook):
19+
"""Hook to update ports according to LLDP data."""
20+
21+
dependencies = ["validate-interfaces"]
22+
23+
def __call__(self, task, inventory, plugin_data):
24+
"""Update Ports' local_link_info and physnet based on LLDP data.
25+
26+
Process the LLDP packet fields for each NIC in the inventory.
27+
28+
Updates attributes of the baremetal port:
29+
30+
- local_link_info.port_id (e.g. "Ethernet1/1")
31+
- local_link_info.switch_id (e.g. "aa:bb:cc:dd:ee:ff")
32+
- local_link_info.switch_info (e.g. "a1-1-1.ord1")
33+
- physical_network (e.g. "a1-1-network")
34+
35+
Also adds or removes node "traits" based on the inventory data. We
36+
control the trait "CUSTOM_STORAGE_SWITCH".
37+
"""
38+
lldp_raw = plugin_data.get("lldp_raw") or {}
39+
node_uuid = task.node.id
40+
interfaces = inventory["interfaces"]
41+
# The all_interfaces field in plugin_data is provided by the
42+
# validate-interfaces hook, so it is a dependency for this hook
43+
all_interfaces = plugin_data["all_interfaces"]
44+
context = task.context
45+
vlan_groups = set()
46+
47+
for iface in interfaces:
48+
if iface["name"] not in all_interfaces:
49+
# This interface was not "validated" so don't bother with it
50+
continue
51+
52+
mac_address = iface["mac_address"]
53+
port = objects.port.Port.get_by_address(context, mac_address)
54+
if not port:
55+
LOG.debug(
56+
"Skipping LLDP processing for interface %s of node "
57+
"%s: matching port not found in Ironic.",
58+
mac_address,
59+
node_uuid,
60+
)
61+
continue
62+
63+
lldp_data = lldp_raw.get(iface["name"]) or iface.get("lldp")
64+
if lldp_data is None:
65+
LOG.warning(
66+
"No LLDP data found for interface %s of node %s",
67+
mac_address,
68+
node_uuid,
69+
)
70+
continue
71+
72+
local_link_connection = _parse_lldp(lldp_data, node_uuid)
73+
vlan_group = vlan_group_name(local_link_connection)
74+
75+
_set_local_link_connection(port, node_uuid, local_link_connection)
76+
_update_port_physical_network(port, vlan_group)
77+
vlan_groups.add(vlan_group)
78+
_update_node_traits(task, vlan_groups)
79+
80+
81+
def _set_local_link_connection(port: Any, node_uuid: str, local_link_connection: dict):
82+
try:
83+
LOG.debug("Updating port %s for node %s", port.address, node_uuid)
84+
port.local_link_connection = local_link_connection
85+
port.save()
86+
except exception.IronicException as e:
87+
LOG.warning(
88+
"Failed to update port %(uuid)s for node %(node)s. Error: %(error)s",
89+
{"uuid": port.id, "node": node_uuid, "error": e},
90+
)
91+
92+
93+
def _parse_lldp(lldp_data: list, node_id: str) -> dict[str, str]:
94+
"""Convert Ironic's "lldp_raw" format to local_link dict."""
95+
try:
96+
decoded = {}
97+
for tlv_type, tlv_value in lldp_data:
98+
if tlv_type not in decoded:
99+
decoded[tlv_type] = []
100+
decoded[tlv_type].append(bytearray(binascii.unhexlify(tlv_value)))
101+
102+
return {
103+
"port_id": _extract_port_id(decoded),
104+
"switch_id": _extract_switch_id(decoded),
105+
"switch_info": _extract_hostname(decoded),
106+
}
107+
except (binascii.Error, core.MappingError, netaddr.AddrFormatError) as e:
108+
LOG.warning("Failed to parse lldp_raw data for Node %s: %s", node_id, e)
109+
return {}
110+
111+
112+
def _extract_port_id(data: dict) -> str | None:
113+
for value in data.get(lldp_tlvs.LLDP_TLV_PORT_ID, []):
114+
parsed = lldp_tlvs.PortId.parse(value)
115+
if parsed.value: # pyright: ignore reportAttributeAccessIssue
116+
return parsed.value.value # pyright: ignore reportAttributeAccessIssue
117+
118+
119+
def _extract_switch_id(data: dict) -> str | None:
120+
for value in data.get(lldp_tlvs.LLDP_TLV_CHASSIS_ID, []):
121+
parsed = lldp_tlvs.ChassisId.parse(value)
122+
if "mac_address" in parsed.subtype: # pyright: ignore reportAttributeAccessIssue
123+
return str(parsed.value.value) # pyright: ignore reportAttributeAccessIssue
124+
125+
126+
def _extract_hostname(data: dict) -> str | None:
127+
for value in data.get(lldp_tlvs.LLDP_TLV_SYS_NAME, []):
128+
parsed = lldp_tlvs.SysName.parse(value)
129+
if parsed.value: # pyright: ignore reportAttributeAccessIssue
130+
return parsed.value # pyright: ignore reportAttributeAccessIssue
131+
132+
133+
def vlan_group_name(local_link_connection) -> str | None:
134+
switch_name = local_link_connection.get("switch_info")
135+
if not switch_name:
136+
return
137+
138+
return ironic_understack.vlan_group_name_convention.vlan_group_name(switch_name)
139+
140+
141+
def _update_port_physical_network(port, new_physical_network: str|None):
142+
old_physical_network = port.physical_network
143+
144+
if new_physical_network == old_physical_network:
145+
return
146+
147+
LOG.debug(
148+
"Updating port %s physical_network from %s to %s",
149+
port.id,
150+
old_physical_network,
151+
new_physical_network,
152+
)
153+
port.physical_network = new_physical_network
154+
port.save()
155+
156+
157+
def _update_node_traits(task, vlan_groups: set[str]):
158+
"""Add or remove traits to the node.
159+
160+
We manage one trait: "CUSTOM_STORAGE_SWITCH" which is added if the node has
161+
any ports connected to a storage fabric, othwise it is removed from the
162+
node.
163+
"""
164+
TRAIT_STORAGE_SWITCH = "CUSTOM_STORAGE_SWITCH"
165+
166+
storage_vlan_groups = {
167+
x for x in vlan_groups if x.endswith("-storage")
168+
}
169+
170+
if storage_vlan_groups:
171+
task.node.add_trait(TRAIT_STORAGE_SWITCH)
172+
else:
173+
try:
174+
task.node.remove_trait(TRAIT_STORAGE_SWITCH)
175+
except openstack.exceptions.NotFoundException:
176+
pass
177+
178+
task.node.save()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
def vlan_group_name(switch_name: str) -> str:
2+
"""The VLAN GROUP name is a function of the switch name.
3+
4+
Switch hostname convention is site-dependent, but in Rackspace all
5+
top-of-rack switch names follow the convention: <cabinet-name>-<suffix>
6+
Example switch names include a2-12-1f and a2-12-1. (These are normally
7+
qualified with a site-specific domain name like a2-12-1.iad3.rackspace.net,
8+
but we are only considering the unqualified name, ignoring everything after
9+
the first dot).
10+
11+
It easy to parse the switch name into cabinet and suffix. Convert the
12+
switch-name-suffix to vlan-group-suffix using the following mapping:
13+
14+
1 → network
15+
2 → network
16+
1f → storage
17+
2f → storage
18+
3f → storage-appliance
19+
4f → storage-appliance
20+
1d → bmc
21+
22+
The VLAN GROUP name results from joining the cabinet name to the new suffix
23+
with a hyphen. The result is in lower case: <cabinet-name>-<vlan-group-suffix>
24+
25+
So for example, switch a2-12-1 is in VLAN GROUP a2-12-network.
26+
"""
27+
# Remove domain suffix if present (everything after first dot)
28+
switch_name = switch_name.split(".")[0].lower()
29+
30+
# Split into cabinet and suffix (last component after last hyphen)
31+
parts = switch_name.rsplit("-", 1)
32+
if len(parts) != 2:
33+
raise ValueError(f"Unknown switch name format: {switch_name}")
34+
35+
cabinet_name, suffix = parts
36+
37+
# Map suffix to VLAN group suffix
38+
suffix_mapping = {
39+
"1": "network",
40+
"2": "network",
41+
"3": "network",
42+
"4": "network",
43+
"1f": "storage",
44+
"2f": "storage",
45+
"3f": "storage-appliance",
46+
"4f": "storage-appliance",
47+
"1d": "bmc",
48+
}
49+
50+
vlan_suffix = suffix_mapping.get(suffix)
51+
if vlan_suffix is None:
52+
raise ValueError(f"Unknown switch suffix: {suffix}")
53+
54+
return f"{cabinet_name}-{vlan_suffix}"

python/ironic-understack/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ readme = "README.md"
1212
license = "MIT"
1313
dependencies = [
1414
"ironic>=24.1",
15+
"pytest-mock>=3.15.1",
1516
"pyyaml~=6.0",
1617
"understack-flavor-matcher",
1718
]
@@ -20,6 +21,7 @@ dependencies = [
2021
resource-class = "ironic_understack.resource_class:UndercloudResourceClassHook"
2122

2223
[project.entry-points."ironic.hardware.interfaces.inspect"]
24+
update-baremetal-port = "ironic_understack.update_baremetal_port:UpdateBaremetalPortsHook"
2325
redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackRedfishInspect"
2426
idrac-redfish-understack = "ironic_understack.redfish_inspect_understack:UnderstackDracRedfishInspect"
2527

python/ironic-understack/uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)